<# QuestGuideUploader.ps1 Windows-Werkzeug zum Auslesen der Missionsdaten und Hochladen an die QuestGuide-API. Kanal waehlbar. Es gibt KEINE feste Liste: Jeder Ordner unter dem Installationsverzeichnis ist ein Kanal - LIVE, PTU, HOTFIX, EPTU, TECH-PREVIEW, und was CIG morgen einfuehrt. Kanaele, die der Launcher noch laedt, stehen mit in der Liste, aber mit dem Grund statt einem Build und ohne Hochladeknopf. Start mit Doppelklick ueber die beiliegende .cmd, oder: powershell -ExecutionPolicy Bypass -File QuestGuideUploader.ps1 Kopfloser Lauf fuer Automatisierung oder Test: ... -NoGui -Channel PTU Zugangsdaten liegen in %APPDATA%\SCQuestGuide\config.json (nur lesbar fuer den angemeldeten Benutzer) - NICHT im Projektverzeichnis, damit sie nicht versehentlich in ein Repo geraten. #> # CmdletBinding ist hier kein Beiwerk: Ohne das Attribut nimmt PowerShell # unbekannte Argumente wortlos in $args entgegen. Ein vertipptes oder # erfundenes "-DryRun" wuerde dann nicht auffallen - und der Lauf laedt hoch, # obwohl man ihn fuer einen Trockenlauf haelt. [CmdletBinding()] param( [switch]$NoGui, [string]$Channel, [string]$ApiBase, [string]$Token, # Extrahieren und anzeigen, aber nichts an den Server schicken. [switch]$WhatIfOnly, [switch]$Force ) $ErrorActionPreference = 'Stop' [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 $SC_ROOT = 'C:\Program Files\Roberts Space Industries\StarCitizen' $CONF_DIR = Join-Path $env:APPDATA 'SCQuestGuide' $CONF_FILE = Join-Path $CONF_DIR 'config.json' # Arbeitsdateien gehoeren NICHT nach %APPDATA%. Das Verzeichnis wandert in # verwalteten Netzen mit dem Profil mit, und eine ausgepackte global.ini ist # 10 MB - je Kanal. Sie liegen deshalb im TEMP und werden nach dem Lauf # wieder geloescht; nur die kleine config.json bleibt dauerhaft. $ARBEIT_DIR = Join-Path ([IO.Path]::GetTempPath()) 'SCQuestGuide' # Sprachfassung: siehe $LOCALES weiter unten - der Uploader nimmt die erste, # die er findet, statt auf eine feste zu bestehen. # =========================================================================== # Quelltext des ZStandard-Entpackers # =========================================================================== # # Er steht hier im Klartext und wird beim ersten Bedarf uebersetzt. Eine # mitgelieferte DLL waere kuerzer, koennte aber niemand nachpruefen - und # genau das soll bei einem Werkzeug moeglich bleiben, das man sich aus dem # Netz herunterlaedt. $ZSTD_QUELLE = @' // --------------------------------------------------------------------------- // ZStandard-Entpacker (nur Entpacken, kein Packen) // // Warum eigener Code statt einer Bibliothek: Das Werkzeug wird als lesbares // Skript zum Herunterladen angeboten. Eine mitgelieferte DLL koennte niemand // nachpruefen, und das Windows-eigene bsdtar scheitert an CIGs ZIP-Zusatzfeld. // Hier steht alles im Klartext. // // Bewusst schlicht: keine Zeiger, kein Span, kein modernes C#. Uebersetzt // wird zur Laufzeit mit dem Compiler, der auf jedem Windows liegt // (Framework 4.0) - und der kennt nur C# 5. // // Umfang: Rohbloecke, Wiederholungsbloecke, komprimierte Bloecke, Huffman- // Literale (ein- und vierstroemig, mit und ohne neuen Baum), FSE-Sequenzen // einschliesslich der drei vordefinierten Tabellen. Woerterbuecher werden // NICHT unterstuetzt - die p4k benutzt keine. // // Nach RFC 8878. // --------------------------------------------------------------------------- using System; namespace QuestGuide { public class ZstdFehler : Exception { public ZstdFehler(string m) : base(m) { } } // ----------------------------------------------------------------------- // Bitleser, der RUECKWAERTS liest. // // Zstd schreibt seine Bitstroeme von hinten nach vorn: Das letzte Byte // kommt zuerst, innerhalb eines Bytes das hoechstwertige Bit zuerst. Im // letzten Byte steht eine 1 als Endmarkierung; alles darueber ist // Fuellung und wird uebersprungen. // // Bit fuer Bit statt mit einem 64-Bit-Fenster: langsamer, aber ohne die // Randfaelle, an denen sich solche Fenster gern verrechnen. Fuer eine // 10-MB-Datei bleibt es im Sekundenbereich. // ----------------------------------------------------------------------- internal class RueckBits { private byte[] q; private int start; private int len; private long bitPos; // gelesene Bits, vom Ende der Daten gezaehlt private long bitEnde; public RueckBits(byte[] quelle, int offset, int laenge) { if (laenge < 1) throw new ZstdFehler("leerer Bitstrom"); q = quelle; start = offset; len = laenge; byte letztes = q[start + len - 1]; if (letztes == 0) throw new ZstdFehler("Bitstrom ohne Endmarkierung"); bitEnde = (long)laenge * 8; // Fuellbits bis einschliesslich der Markierung ueberspringen bitPos = 8 - HoechstesBit(letztes); } internal static int HoechstesBit(uint v) { int n = -1; while (v != 0) { v >>= 1; n++; } return n; } private int BitBei(long i) { if (i >= bitEnde) return 0; // hinter dem Anfang: Nullen int byteIdx = start + len - 1 - (int)(i >> 3); int bitIdx = 7 - (int)(i & 7); return (q[byteIdx] >> bitIdx) & 1; } /// n Bits ansehen, ohne sie zu verbrauchen. public uint Sicht(int n) { uint v = 0; for (int i = 0; i < n; i++) v = (v << 1) | (uint)BitBei(bitPos + i); return v; } public void Ueberspringe(int n) { bitPos += n; } public uint Nimm(int n) { if (n == 0) return 0; uint v = Sicht(n); bitPos += n; return v; } /// Es wurde ueber das Ende hinaus gelesen - der Strom ist zu Ende. public bool Ueberzogen { get { return bitPos > bitEnde; } } } // ----------------------------------------------------------------------- // FSE - die Entropiekodierung von Zstd // ----------------------------------------------------------------------- internal class FseTabelle { public int TableLog; public byte[] Symbol; public byte[] NbBits; public ushort[] NewState; public static FseTabelle Baue(short[] norm, int maxSymbol, int tableLog) { FseTabelle t = new FseTabelle(); int size = 1 << tableLog; t.TableLog = tableLog; t.Symbol = new byte[size]; t.NbBits = new byte[size]; t.NewState = new ushort[size]; byte[] tabSym = new byte[size]; int[] naechster = new int[maxSymbol + 2]; int hoch = size - 1; for (int s = 0; s <= maxSymbol; s++) { if (norm[s] == -1) { tabSym[hoch--] = (byte)s; naechster[s] = 1; } else naechster[s] = norm[s]; } int schritt = (size >> 1) + (size >> 3) + 3; int maske = size - 1; int stelle = 0; for (int s = 0; s <= maxSymbol; s++) { for (int i = 0; i < norm[s]; i++) { tabSym[stelle] = (byte)s; stelle = (stelle + schritt) & maske; while (stelle > hoch) stelle = (stelle + schritt) & maske; } } if (stelle != 0) throw new ZstdFehler("FSE-Verteilung geht nicht auf"); for (int u = 0; u < size; u++) { byte sym = tabSym[u]; int n = naechster[sym]++; int nb = tableLog - RueckBits.HoechstesBit((uint)n); t.Symbol[u] = sym; t.NbBits[u] = (byte)nb; t.NewState[u] = (ushort)((n << nb) - size); } return t; } /// Normalisierte Haeufigkeiten lesen. Liefert verbrauchte Bytes. public static int LiesNCount(byte[] q, int off, int max, short[] norm, ref int maxSymbol, ref int tableLog) { int istart = off; int iend = off + max; int ip = off; if (max < 4) throw new ZstdFehler("NCount zu kurz"); int bitStream = (int)LiesLE32(q, ip, iend); int bitCount = 4; int log = (bitStream & 0xF) + 5; if (log > 12) throw new ZstdFehler("tableLog zu gross: " + log); tableLog = log; bitStream >>= 4; int remaining = (1 << log) + 1; int threshold = 1 << log; int nbBits = log + 1; int charnum = 0; bool previous0 = false; while (remaining > 1 && charnum <= maxSymbol) { if (previous0) { int n0 = charnum; while ((bitStream & 0xFFFF) == 0xFFFF) { n0 += 24; if (ip < iend - 5) { ip += 2; bitStream = (int)(LiesLE32(q, ip, iend) >> bitCount); } else { bitStream = (int)((uint)bitStream >> 16); bitCount += 16; } } while ((bitStream & 3) == 3) { n0 += 3; bitStream = (int)((uint)bitStream >> 2); bitCount += 2; } n0 += bitStream & 3; bitCount += 2; if (n0 > maxSymbol) throw new ZstdFehler("NCount: zu viele Symbole"); while (charnum < n0) norm[charnum++] = 0; if ((ip <= iend - 7) || (ip + (bitCount >> 3) <= iend - 4)) { ip += bitCount >> 3; bitCount &= 7; bitStream = (int)(LiesLE32(q, ip, iend) >> bitCount); } else { bitStream = (int)((uint)bitStream >> 2); } previous0 = false; continue; } int maxWert = (2 * threshold - 1) - remaining; int count; if ((bitStream & (threshold - 1)) < maxWert) { count = bitStream & (threshold - 1); bitCount += nbBits - 1; } else { count = bitStream & (2 * threshold - 1); if (count >= threshold) count -= maxWert; bitCount += nbBits; } count--; // -1 heisst "weniger als eins" remaining -= count < 0 ? -count : count; if (charnum > maxSymbol) throw new ZstdFehler("NCount: Symbol ausserhalb"); norm[charnum++] = (short)count; previous0 = (count == 0); while (remaining < threshold) { nbBits--; threshold >>= 1; } if ((ip <= iend - 7) || (ip + (bitCount >> 3) <= iend - 4)) { ip += bitCount >> 3; bitCount &= 7; } else { bitCount -= (int)(8 * (iend - 4 - ip)); ip = iend - 4; } bitStream = (int)(LiesLE32(q, ip, iend) >> (bitCount & 31)); } if (remaining != 1) throw new ZstdFehler("NCount unvollstaendig"); while (charnum <= maxSymbol) norm[charnum++] = 0; maxSymbol = charnum - 1; ip += (bitCount + 7) >> 3; return ip - istart; } private static uint LiesLE32(byte[] q, int off, int ende) { uint r = 0; for (int i = 0; i < 4; i++) { int k = off + i; byte b = (k < ende && k < q.Length && k >= 0) ? q[k] : (byte)0; r |= (uint)b << (8 * i); } return r; } } // ----------------------------------------------------------------------- // Huffman fuer die Literale // ----------------------------------------------------------------------- internal class HuffTabelle { public int MaxBits; public byte[] Symbol; public byte[] Bits; public static HuffTabelle AusGewichten(byte[] gewicht, int anzahl) { uint summe = 0; for (int i = 0; i < anzahl; i++) if (gewicht[i] > 0) summe += (uint)(1 << (gewicht[i] - 1)); if (summe == 0) throw new ZstdFehler("Huffman ohne Gewichte"); int maxBits = RueckBits.HoechstesBit(summe) + 1; if (maxBits > 12) throw new ZstdFehler("Huffman zu tief: " + maxBits); uint rest = (uint)(1 << maxBits) - summe; if (rest == 0 || (rest & (rest - 1)) != 0) throw new ZstdFehler(string.Format( "Huffman-Restgewicht ist keine Zweierpotenz (anzahl={0} summe={1} maxBits={2} rest={3})", anzahl, summe, maxBits, rest)); gewicht[anzahl] = (byte)(RueckBits.HoechstesBit(rest) + 1); anzahl++; HuffTabelle h = new HuffTabelle(); h.MaxBits = maxBits; int size = 1 << maxBits; h.Symbol = new byte[size]; h.Bits = new byte[size]; int[] rangAnzahl = new int[maxBits + 2]; for (int i = 0; i < anzahl; i++) if (gewicht[i] > 0) rangAnzahl[gewicht[i]]++; // Ein Symbol mit Gewicht w hat die Codelaenge maxBits+1-w und belegt // damit 2^(w-1) Plaetze. // // Die Reihenfolge ist AUFSTEIGEND nach Gewicht - also die langen // Codes zuerst. Andersherum entpackt es sich anstandslos bis zur // richtigen Groesse, nur steht dann Kauderwelsch drin. int[] rangStart = new int[maxBits + 2]; int pos = 0; for (int w = 1; w <= maxBits; w++) { rangStart[w] = pos; pos += rangAnzahl[w] * (1 << (w - 1)); } if (pos != size) throw new ZstdFehler("Huffman-Tabelle geht nicht auf"); for (int s = 0; s < anzahl; s++) { int w = gewicht[s]; if (w == 0) continue; int nb = maxBits - w + 1; int n = 1 << (w - 1); for (int i = 0; i < n; i++) { h.Symbol[rangStart[w] + i] = (byte)s; h.Bits[rangStart[w] + i] = (byte)nb; } rangStart[w] += n; } return h; } } // ----------------------------------------------------------------------- // Der Entpacker // ----------------------------------------------------------------------- public static class Zstd { private const uint MagieRahmen = 0xFD2FB528; private static readonly int[] LlBasis = { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,18,20,22,24,28,32,40,48,64, 128,256,512,1024,2048,4096,8192,16384,32768,65536 }; private static readonly int[] LlExtra = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,2,2,3,3,4,6,7,8,9,10,11,12,13,14,15,16 }; private static readonly int[] MlBasis = { 3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32, 33,34,35,37,39,41,43,47,51,59,67,83,99,131,259,515,1027,2051,4099,8195,16387,32771,65539 }; private static readonly int[] MlExtra = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 1,1,1,1,2,2,3,3,4,4,5,7,8,9,10,11,12,13,14,15,16 }; private static readonly short[] LlVorgabe = { 4,3,2,2,2,2,2,2,2,2,2,2,2,1,1,1,2,2,2,2,2,2,2,2,2,3,2,1,1,1,1,1,-1,-1,-1,-1 }; private static readonly short[] MlVorgabe = { 1,4,3,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1 }; private static readonly short[] OfVorgabe = { 1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1 }; /// Nur zum Nachsehen beim Entwickeln: Rahmen und erster Block. public static string Diagnose(byte[] q, int off, int len) { System.Text.StringBuilder sb = new System.Text.StringBuilder(); int p = off; uint magie = LiesU32(q, p); p += 4; sb.AppendLine(string.Format("Magie 0x{0:X8}", magie)); byte fhd = q[p++]; int fcsFeld = (fhd >> 6) & 3; bool einSegment = ((fhd >> 5) & 1) != 0; bool pruef = ((fhd >> 2) & 1) != 0; int dictFeld = fhd & 3; sb.AppendLine(string.Format("Kopf 0x{0:X2}: fcsFeld={1} einSegment={2} pruefsumme={3} dict={4}", fhd, fcsFeld, einSegment, pruef, dictFeld)); if (!einSegment) { sb.AppendLine("Fenster 0x" + q[p].ToString("X2")); p++; } int dl = dictFeld == 3 ? 4 : dictFeld; p += dl; int fcsLen = fcsFeld == 0 ? (einSegment ? 1 : 0) : (1 << fcsFeld); long inhalt = -1; if (fcsLen > 0) { inhalt = 0; for (int i = 0; i < fcsLen; i++) inhalt |= ((long)q[p + i]) << (8 * i); if (fcsLen == 2) inhalt += 256; p += fcsLen; } sb.AppendLine("Inhaltsgroesse laut Rahmen: " + inhalt); int kopf = q[p] | (q[p + 1] << 8) | (q[p + 2] << 16); p += 3; sb.AppendLine(string.Format("Block: letzter={0} art={1} groesse={2}", (kopf & 1) != 0, (kopf >> 1) & 3, kopf >> 3)); if (((kopf >> 1) & 3) != 2) return sb.ToString(); byte lh = q[p]; int lArt = lh & 3, format = (lh >> 2) & 3; sb.AppendLine(string.Format("Literale: art={0} format={1} (kopfbyte 0x{2:X2})", lArt, format, lh)); int klar2, roh2, stroeme2, q2 = p; if (format == 0) { klar2 = ((lh >> 4) & 0x3F) | ((q[q2+1] & 0x3F) << 4); roh2 = (q[q2+1] >> 6) | (q[q2+2] << 2); q2 += 3; stroeme2 = 1; } else if (format == 1) { klar2 = ((lh >> 4) & 0x3F) | ((q[q2+1] & 0x3F) << 4); roh2 = (q[q2+1] >> 6) | (q[q2+2] << 2); q2 += 3; stroeme2 = 4; } else if (format == 2) { klar2 = (lh >> 4) | (q[q2+1] << 4) | ((q[q2+2] & 3) << 12); roh2 = (q[q2+2] >> 2) | (q[q2+3] << 6); q2 += 4; stroeme2 = 4; } else { klar2 = (lh >> 4) | (q[q2+1] << 4) | ((q[q2+2] & 0x3F) << 12); roh2 = (q[q2+2] >> 6) | (q[q2+3] << 2) | (q[q2+4] << 10); q2 += 5; stroeme2 = 4; } sb.AppendLine(string.Format(" klar={0} roh={1} stroeme={2}", klar2, roh2, stroeme2)); if (lArt == 2) { byte hk = q[q2]; sb.AppendLine(string.Format(" Baumkopf 0x{0:X2} ({1})", hk, hk >= 128 ? "direkt" : "FSE, " + hk + " Bytes")); if (hk < 128) { short[] norm = new short[256]; int ms = 255, log = 0; int n = FseTabelle.LiesNCount(q, q2 + 1, hk, norm, ref ms, ref log); sb.AppendLine(string.Format(" NCount: {0} Bytes, tableLog={1}, maxSymbol={2}", n, log, ms)); System.Text.StringBuilder nb = new System.Text.StringBuilder(); for (int i = 0; i <= ms; i++) { nb.Append(norm[i]); nb.Append(' '); } sb.AppendLine(" norm: " + nb.ToString().Trim()); FseTabelle t = FseTabelle.Baue(norm, ms, log); RueckBits bits = new RueckBits(q, q2 + 1 + n, hk - n); int s1 = (int)bits.Nimm(log), s2 = (int)bits.Nimm(log); byte[] gew = new byte[256]; int anz = 0; while (true) { gew[anz++] = t.Symbol[s1]; s1 = t.NewState[s1] + (int)bits.Nimm(t.NbBits[s1]); if (bits.Ueberzogen || anz >= 255) break; gew[anz++] = t.Symbol[s2]; s2 = t.NewState[s2] + (int)bits.Nimm(t.NbBits[s2]); if (bits.Ueberzogen || anz >= 255) break; } System.Text.StringBuilder gb = new System.Text.StringBuilder(); for (int i = 0; i < anz; i++) { gb.Append(gew[i]); gb.Append(' '); } sb.AppendLine(" " + anz + " Gewichte: " + gb.ToString().Trim()); uint summe = 0; for (int i = 0; i < anz; i++) if (gew[i] > 0) summe += (uint)(1 << (gew[i] - 1)); sb.AppendLine(" Summe=" + summe + " hoechstesBit=" + RueckBits.HoechstesBit(summe)); } } return sb.ToString(); } public static byte[] Entpacke(byte[] q, int off, int len, long erwartet) { int p = off; int ende = off + len; uint magie = LiesU32(q, p); p += 4; while (magie != MagieRahmen) { if ((magie & 0xFFFFFFF0) == 0x184D2A50) // ueberspringbar { uint gr = LiesU32(q, p); p += 4 + (int)gr; if (p + 4 > ende) throw new ZstdFehler("nur ueberspringbare Rahmen"); magie = LiesU32(q, p); p += 4; continue; } throw new ZstdFehler(string.Format("kein Zstd-Rahmen (0x{0:X8})", magie)); } byte fhd = q[p++]; int fcsFeld = (fhd >> 6) & 3; bool einSegment = ((fhd >> 5) & 1) != 0; bool pruefsumme = ((fhd >> 2) & 1) != 0; int dictFeld = fhd & 3; if (((fhd >> 3) & 1) != 0) throw new ZstdFehler("reserviertes Bit gesetzt"); if (!einSegment) p++; // Fensterbeschreibung int dictLen = dictFeld == 3 ? 4 : dictFeld; if (dictLen > 0) { long dictId = 0; for (int i = 0; i < dictLen; i++) dictId |= ((long)q[p + i]) << (8 * i); p += dictLen; if (dictId != 0) throw new ZstdFehler("Woerterbuecher werden nicht unterstuetzt"); } long inhalt = -1; int fcsLen = fcsFeld == 0 ? (einSegment ? 1 : 0) : (1 << fcsFeld); if (fcsLen > 0) { inhalt = 0; for (int i = 0; i < fcsLen; i++) inhalt |= ((long)q[p + i]) << (8 * i); if (fcsLen == 2) inhalt += 256; p += fcsLen; } long kap = erwartet > 0 ? erwartet : (inhalt > 0 ? inhalt : 1 << 20); if (kap > int.MaxValue) throw new ZstdFehler("Inhalt zu gross"); byte[] aus = new byte[kap]; int ausPos = 0; HuffTabelle huff = null; FseTabelle llT = null, ofT = null, mlT = null; int rleLl = -1, rleOf = -1, rleMl = -1; // Die drei Wiederholungsabstaende gehoeren dem RAHMEN, nicht dem // Block: Ein Treffer im zweiten Block darf sich auf einen Abstand // aus dem ersten beziehen. Setzt man sie je Block zurueck, entpackt // alles bis zur richtigen Groesse - nur steht ab der ersten // Blockgrenze hier und da Unsinn. int[] letzte = { 1, 4, 8 }; while (true) { if (p + 3 > ende) throw new ZstdFehler("Blockkopf abgeschnitten"); int kopf = q[p] | (q[p + 1] << 8) | (q[p + 2] << 16); p += 3; bool letzter = (kopf & 1) != 0; int art = (kopf >> 1) & 3; int gr2 = kopf >> 3; if (art == 0) { aus = Sicher(aus, ausPos + gr2); Array.Copy(q, p, aus, ausPos, gr2); ausPos += gr2; p += gr2; } else if (art == 1) { aus = Sicher(aus, ausPos + gr2); byte b = q[p++]; for (int i = 0; i < gr2; i++) aus[ausPos++] = b; } else if (art == 2) { ausPos = Block(q, p, gr2, ref aus, ausPos, ref huff, ref llT, ref ofT, ref mlT, ref rleLl, ref rleOf, ref rleMl, letzte); p += gr2; } else throw new ZstdFehler("reservierte Blockart"); if (letzter) break; } if (pruefsumme) p += 4; if (ausPos != aus.Length) { byte[] genau = new byte[ausPos]; Array.Copy(aus, genau, ausPos); return genau; } return aus; } private static byte[] Sicher(byte[] a, int noetig) { if (noetig <= a.Length) return a; int neu = a.Length == 0 ? 65536 : a.Length; while (neu < noetig) neu *= 2; byte[] b = new byte[neu]; Array.Copy(a, b, a.Length); return b; } private static int Block(byte[] q, int off, int len, ref byte[] aus, int ausPos, ref HuffTabelle huff, ref FseTabelle llT, ref FseTabelle ofT, ref FseTabelle mlT, ref int rleLl, ref int rleOf, ref int rleMl, int[] letzte) { int p = off; int ende = off + len; // ---------- Literale ---------- byte lh = q[p]; int lArt = lh & 3; byte[] literale; int litLen; if (lArt == 0 || lArt == 1) { int format = (lh >> 2) & 3; int gr; if (format == 0 || format == 2) { gr = lh >> 3; p += 1; } else if (format == 1) { gr = (lh >> 4) | (q[p + 1] << 4); p += 2; } else { gr = (lh >> 4) | (q[p + 1] << 4) | (q[p + 2] << 12); p += 3; } literale = new byte[gr]; if (lArt == 0) { Array.Copy(q, p, literale, 0, gr); p += gr; } else { byte b = q[p++]; for (int i = 0; i < gr; i++) literale[i] = b; } litLen = gr; } else { int format = (lh >> 2) & 3; int klar, roh, stroeme; if (format == 0) { klar = ((lh >> 4) & 0x3F) | ((q[p + 1] & 0x3F) << 4); roh = (q[p + 1] >> 6) | (q[p + 2] << 2); p += 3; stroeme = 1; } else if (format == 1) { klar = ((lh >> 4) & 0x3F) | ((q[p + 1] & 0x3F) << 4); roh = (q[p + 1] >> 6) | (q[p + 2] << 2); p += 3; stroeme = 4; } else if (format == 2) { klar = (lh >> 4) | (q[p + 1] << 4) | ((q[p + 2] & 3) << 12); roh = (q[p + 2] >> 2) | (q[p + 3] << 6); p += 4; stroeme = 4; } else { klar = (lh >> 4) | (q[p + 1] << 4) | ((q[p + 2] & 0x3F) << 12); roh = (q[p + 2] >> 6) | (q[p + 3] << 2) | (q[p + 4] << 10); p += 5; stroeme = 4; } // "roh" umfasst Baumbeschreibung UND Stroeme. int abschnitt = p; int baum = 0; if (lArt == 2) { huff = LiesHuffKopf(q, p, ende, out baum); } else if (huff == null) throw new ZstdFehler("Literale ohne Baum"); literale = new byte[klar]; DekodiereLiterale(q, abschnitt + baum, roh - baum, stroeme, huff, literale); p = abschnitt + roh; litLen = klar; } // ---------- Sequenzen ---------- int anzahl; byte b0 = q[p++]; if (b0 == 0) anzahl = 0; else if (b0 < 128) anzahl = b0; else if (b0 < 255) { anzahl = ((b0 - 128) << 8) + q[p++]; } else { anzahl = q[p] + (q[p + 1] << 8) + 0x7F00; p += 2; } if (anzahl == 0) { aus = Sicher(aus, ausPos + litLen); Array.Copy(literale, 0, aus, ausPos, litLen); return ausPos + litLen; } byte modi = q[p++]; int llM = (modi >> 6) & 3; int ofM = (modi >> 4) & 3; int mlM = (modi >> 2) & 3; p += LiesTabelle(q, p, ende, llM, 35, 9, LlVorgabe, 6, ref llT, ref rleLl); p += LiesTabelle(q, p, ende, ofM, 31, 8, OfVorgabe, 5, ref ofT, ref rleOf); p += LiesTabelle(q, p, ende, mlM, 52, 9, MlVorgabe, 6, ref mlT, ref rleMl); RueckBits bits = new RueckBits(q, p, ende - p); // Anfangszustaende: Literallaenge, Abstand, Trefferlaenge - in dieser Reihenfolge. int llS = llM == 1 ? 0 : (int)bits.Nimm(llT.TableLog); int ofS = ofM == 1 ? 0 : (int)bits.Nimm(ofT.TableLog); int mlS = mlM == 1 ? 0 : (int)bits.Nimm(mlT.TableLog); int litPos = 0; for (int i = 0; i < anzahl; i++) { int llCode = llM == 1 ? rleLl : llT.Symbol[llS]; int ofCode = ofM == 1 ? rleOf : ofT.Symbol[ofS]; int mlCode = mlM == 1 ? rleMl : mlT.Symbol[mlS]; if (llCode >= LlBasis.Length) throw new ZstdFehler("Literallaengen-Code zu gross"); if (mlCode >= MlBasis.Length) throw new ZstdFehler("Trefferlaengen-Code zu gross"); if (ofCode > 31) throw new ZstdFehler("Abstandscode zu gross"); // Reihenfolge der Zusatzbits: Abstand, Trefferlaenge, Literallaenge. long offWert = (1L << ofCode) + bits.Nimm(ofCode); int mlWert = MlBasis[mlCode] + (int)bits.Nimm(MlExtra[mlCode]); int llWert = LlBasis[llCode] + (int)bits.Nimm(LlExtra[llCode]); long abstand; if (offWert > 3) { letzte[2] = letzte[1]; letzte[1] = letzte[0]; letzte[0] = (int)(offWert - 3); abstand = letzte[0]; } else { int idx = (int)offWert; if (llWert == 0) idx++; if (idx == 1) { abstand = letzte[0]; } else if (idx == 2) { abstand = letzte[1]; letzte[1] = letzte[0]; letzte[0] = (int)abstand; } else if (idx == 3) { abstand = letzte[2]; letzte[2] = letzte[1]; letzte[1] = letzte[0]; letzte[0] = (int)abstand; } else { abstand = letzte[0] - 1; if (abstand <= 0) throw new ZstdFehler("ungueltiger Wiederholungsabstand"); letzte[2] = letzte[1]; letzte[1] = letzte[0]; letzte[0] = (int)abstand; } } if (litPos + llWert > litLen) throw new ZstdFehler("mehr Literale verlangt als vorhanden"); aus = Sicher(aus, ausPos + llWert + mlWert); for (int k = 0; k < llWert; k++) aus[ausPos++] = literale[litPos++]; if (abstand > ausPos) throw new ZstdFehler("Abstand zeigt vor den Anfang"); int quelle = (int)(ausPos - abstand); for (int k = 0; k < mlWert; k++) aus[ausPos++] = aus[quelle++]; if (i + 1 < anzahl) { if (llM != 1) llS = llT.NewState[llS] + (int)bits.Nimm(llT.NbBits[llS]); if (mlM != 1) mlS = mlT.NewState[mlS] + (int)bits.Nimm(mlT.NbBits[mlS]); if (ofM != 1) ofS = ofT.NewState[ofS] + (int)bits.Nimm(ofT.NbBits[ofS]); } } int rest = litLen - litPos; aus = Sicher(aus, ausPos + rest); for (int k = 0; k < rest; k++) aus[ausPos++] = literale[litPos++]; return ausPos; } private static int LiesTabelle(byte[] q, int off, int ende, int modus, int maxSymbol, int maxLog, short[] vorgabe, int vorgabeLog, ref FseTabelle t, ref int rle) { if (modus == 0) { t = FseTabelle.Baue(vorgabe, vorgabe.Length - 1, vorgabeLog); return 0; } if (modus == 1) { rle = q[off]; return 1; } if (modus == 3) { if (t == null) throw new ZstdFehler("Wiederholung ohne vorherige Tabelle"); return 0; } short[] norm = new short[maxSymbol + 2]; int ms = maxSymbol; int log = 0; int n = FseTabelle.LiesNCount(q, off, ende - off, norm, ref ms, ref log); if (log > maxLog) throw new ZstdFehler("tableLog zu gross"); t = FseTabelle.Baue(norm, ms, log); return n; } private static HuffTabelle LiesHuffKopf(byte[] q, int off, int ende, out int verbraucht) { int p = off; byte kopf = q[p++]; byte[] gewicht = new byte[256]; int anzahl; if (kopf >= 128) { anzahl = kopf - 127; for (int i = 0; i < anzahl; i++) { byte b = q[p + (i >> 1)]; gewicht[i] = (i & 1) == 0 ? (byte)(b >> 4) : (byte)(b & 0xF); } p += (anzahl + 1) / 2; } else { int len = kopf; short[] norm = new short[256]; int ms = 255; int log = 0; int n = FseTabelle.LiesNCount(q, p, len, norm, ref ms, ref log); if (log > 6) throw new ZstdFehler("Gewichtstabelle zu gross"); FseTabelle t = FseTabelle.Baue(norm, ms, log); // Zwei verschraenkte Zustaende. Ausgegeben wird das Symbol des // aktuellen Zustands, DANACH wird fortgeschrieben. // // Der Abbruch ist die Stelle, an der es leicht schiefgeht: // Laeuft der Strom beim Fortschreiben ueber, haelt der ANDERE // Zustand noch ein gueltiges Symbol. Es gehoert mit ausgegeben, // sonst fehlt genau eines - und die Huffman-Tabelle geht dann // nicht auf. RueckBits bits = new RueckBits(q, p + n, len - n); int s1 = (int)bits.Nimm(log); int s2 = (int)bits.Nimm(log); anzahl = 0; while (anzahl < 254) { gewicht[anzahl++] = t.Symbol[s1]; s1 = t.NewState[s1] + (int)bits.Nimm(t.NbBits[s1]); if (bits.Ueberzogen) { gewicht[anzahl++] = t.Symbol[s2]; break; } gewicht[anzahl++] = t.Symbol[s2]; s2 = t.NewState[s2] + (int)bits.Nimm(t.NbBits[s2]); if (bits.Ueberzogen) { gewicht[anzahl++] = t.Symbol[s1]; break; } } p += len; } verbraucht = p - off; return HuffTabelle.AusGewichten(gewicht, anzahl); } private static void DekodiereLiterale(byte[] q, int off, int roh, int stroeme, HuffTabelle h, byte[] ziel) { if (ziel.Length == 0) return; if (stroeme == 1) { EinStrom(q, off, roh, h, ziel, 0, ziel.Length); return; } int s1 = q[off] | (q[off + 1] << 8); int s2 = q[off + 2] | (q[off + 3] << 8); int s3 = q[off + 4] | (q[off + 5] << 8); int start = off + 6; int s4 = roh - 6 - s1 - s2 - s3; if (s4 < 0) throw new ZstdFehler("Sprungtabelle passt nicht"); int teil = (ziel.Length + 3) / 4; int letzterTeil = ziel.Length - 3 * teil; if (letzterTeil < 0) throw new ZstdFehler("Literalgroesse passt nicht zu vier Stroemen"); EinStrom(q, start, s1, h, ziel, 0, teil); EinStrom(q, start + s1, s2, h, ziel, teil, teil); EinStrom(q, start + s1 + s2, s3, h, ziel, 2 * teil, teil); EinStrom(q, start + s1 + s2 + s3, s4, h, ziel, 3 * teil, letzterTeil); } private static void EinStrom(byte[] q, int off, int len, HuffTabelle h, byte[] ziel, int zielOff, int anzahl) { if (anzahl == 0) return; RueckBits bits = new RueckBits(q, off, len); for (int i = 0; i < anzahl; i++) { int idx = (int)bits.Sicht(h.MaxBits); ziel[zielOff + i] = h.Symbol[idx]; bits.Ueberspringe(h.Bits[idx]); } } private static uint LiesU32(byte[] q, int off) { return (uint)(q[off] | (q[off + 1] << 8) | (q[off + 2] << 16) | (q[off + 3] << 24)); } /// CRC-32 wie im ZIP-Verzeichnis - der Beweis, dass richtig entpackt wurde. public static uint Crc32(byte[] d) { uint[] tab = new uint[256]; for (uint n = 0; n < 256; n++) { uint c = n; for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); tab[n] = c; } uint crc = 0xFFFFFFFFu; for (int i = 0; i < d.Length; i++) crc = tab[(crc ^ d[i]) & 0xFF] ^ (crc >> 8); return crc ^ 0xFFFFFFFFu; } } } '@ # =========================================================================== # Konfiguration # =========================================================================== function Lade-Konfig { if (Test-Path $CONF_FILE) { try { return Get-Content $CONF_FILE -Raw -Encoding UTF8 | ConvertFrom-Json } catch { } } return [pscustomobject]@{ apiBase = 'https://versefiles.com'; token = ''; channel = 'LIVE' } } function Speichere-Konfig($k) { if (-not (Test-Path $CONF_DIR)) { New-Item -ItemType Directory -Path $CONF_DIR -Force | Out-Null } $k | ConvertTo-Json | Set-Content $CONF_FILE -Encoding UTF8 # Nur der angemeldete Benutzer - der Token ist ein Geheimnis. try { icacls $CONF_FILE /inheritance:r /grant:r "$env:USERNAME:(R,W)" | Out-Null } catch { } } # =========================================================================== # Kanaele finden # =========================================================================== # # Jeder Ordner unter $SC_ROOT ist ein Kanal - LIVE, PTU, HOTFIX, EPTU, # TECH-PREVIEW. Es gibt hier bewusst KEINE feste Liste: Was CIG morgen # einfuehrt, taucht von selbst auf. # # Ein Ordner allein reicht aber nicht. Waehrend der Launcher laedt, steht dort # nur eine "Data.p4k.part" - keine Spieldaten, keine Sprachdateien, nichts zu # extrahieren. Solche Kanaele wurden frueher stillschweigend uebergangen: Im # Launcher stand HOTFIX, im Uploader nicht, und niemand konnte sehen warum. # Jetzt kommen sie mit in die Liste, mit dem Grund und ohne Hochladeknopf. # Sprachfassungen in der Reihenfolge, in der sie genommen werden. # # Die Schluessel der Missionen sind in jeder Sprache dieselben; nur Titel und # Beschreibungen unterscheiden sich. Deutsch zuerst, weil die Seite damit # eingelesen wurde - aber wer sein Spiel auf Englisch stehen hat, hatte vorher # gar keine Installation gefunden, ohne zu erfahren warum. $LOCALES = @( 'german_(germany)', 'english', 'french_(france)', 'spanish_(latin_america)', 'spanish_(spain)', 'italian_(italy)', 'polish_(poland)', 'portuguese_(brazil)', 'korean_(south_korea)', 'japanese_(japan)', 'chinese_(simplified)', 'chinese_(traditional)' ) function Finde-Sprachdatei($install) { $wurzel = Join-Path $install 'data\Localization' if (-not (Test-Path $wurzel)) { return $null } foreach ($l in $LOCALES) { $p = Join-Path $wurzel "$l\global.ini" if (Test-Path $p) { return $p } } # Eine Sprache, die wir nicht kennen, ist immer noch besser als keine. $rest = Get-ChildItem $wurzel -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'global.ini' } | Where-Object { Test-Path $_ } | Select-Object -First 1 return $rest } function Finde-Kanaele { if (-not (Test-Path $SC_ROOT)) { return @() } Get-ChildItem $SC_ROOT -Directory -ErrorAction SilentlyContinue | ForEach-Object { $ini = Finde-Sprachdatei $_.FullName $teil = @(Get-ChildItem $_.FullName -Filter '*.part' -ErrorAction SilentlyContinue) $grund = $null # Ohne lose Sprachdatei ist die Data.p4k die Quelle. Ob darin wirklich # eine global.ini steckt, wird ERST beim Hochladen geprueft: Das # Verzeichnis der p4k hat 1,37 Millionen Eintraege, und das bei jedem # Programmstart fuer jeden Kanal zu durchsuchen waere unzumutbar. $p4k = Test-Path (Join-Path $_.FullName 'Data.p4k') if (-not $ini) { $grund = if ($teil.Count) { $gb = [math]::Round((($teil | Measure-Object Length -Sum).Sum) / 1GB, 1) "laedt noch ($gb GB bisher)" } elseif ($p4k) { $null # nutzbar, nur eben aus der p4k } elseif (Test-Path (Join-Path $_.FullName 'data\Localization')) { 'keine Sprachdatei gefunden' } else { 'keine Spieldaten' } } [pscustomobject]@{ Name = $_.Name Pfad = $_.FullName Ini = $ini Sprache = if ($ini) { Split-Path (Split-Path $ini -Parent) -Leaf } else { $null } AusP4k = (-not $ini) -and $p4k # Auch ohne Sprachdatei lesbar, und dann nuetzlich: Man sieht, # welcher Stand da unbrauchbar herumliegt. Build = Lies-Build $_.FullName Version = Lies-Spielversion $_.FullName Datum = if ($ini) { (Get-Item $ini).LastWriteTime } else { $_.LastWriteTime } Bereit = [bool]$ini -or ($p4k -and -not $teil.Count) Grund = $grund } } } # Lesbare Spielversion, z.B. "Alpha 4.10.0". # # Die Buildnummer (12545750) ist eindeutig, aber niemand erkennt sie wieder. # Im Manifest steht der Zweig als "sc-alpha-4.10.0-hotfix" - daraus wird die # Angabe, die auch im Spiel und in CIGs Ankuendigungen steht. function Lies-Spielversion($install) { $m = Join-Path $install 'build_manifest.id' if (-not (Test-Path $m)) { return $null } try { $j = Get-Content $m -Raw -Encoding UTF8 | ConvertFrom-Json $zweig = [string]$j.Data.Branch if ($zweig -match '^sc-([a-z]+)-([0-9][0-9.]*)') { $stufe = (Get-Culture).TextInfo.ToTitleCase($Matches[1]) return ('{0} {1}' -f $stufe, $Matches[2]).Trim() } if ($zweig) { return $zweig } if ($j.Data.Version) { return [string]$j.Data.Version } } catch { } return $null } function Lies-Build($install) { $m = Join-Path $install 'build_manifest.id' if (-not (Test-Path $m)) { return 'unbekannt' } try { $j = Get-Content $m -Raw -Encoding UTF8 | ConvertFrom-Json foreach ($f in 'RequestedP4ChangeNum', 'Version', 'BuildId') { if ($j.Data -and $j.Data.$f) { return [string]$j.Data.$f } if ($j.$f) { return [string]$j.$f } } } catch { } return 'unbekannt' } # =========================================================================== # Aufraeumen # =========================================================================== # Loescht die Arbeitsdateien eines Laufs. Nur die eigenen, an ihrem Namen # erkennbar - im TEMP liegt auch anderer Leute Arbeit. function Raeume-Arbeit([scriptblock]$log) { if (-not (Test-Path $ARBEIT_DIR)) { return } $weg = @(Get-ChildItem $ARBEIT_DIR -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'global.p4k.*.ini' -or $_.Name -like 'global.restored*.ini' }) if (-not $weg.Count) { return } $mb = [math]::Round((($weg | Measure-Object Length -Sum).Sum) / 1MB, 1) foreach ($f in $weg) { Remove-Item $f.FullName -Force -ErrorAction SilentlyContinue } if ($log) { & $log ("Arbeitsdateien geloescht ({0} Dateien, {1} MB)." -f $weg.Count, $mb) } } # Frueherer Stand legte dieselben Dateien neben der Konfiguration ab. Wer von # dort kommt, hat bis zu 10 MB je Kanal im Roaming-Profil liegen - einmalig # wegraeumen, config.json bleibt unangetastet. function Raeume-Altlasten([scriptblock]$log) { if (-not (Test-Path $CONF_DIR)) { return } $alt = @(Get-ChildItem $CONF_DIR -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'global.p4k.*.ini' -or $_.Name -like 'global.restored*.ini' }) if (-not $alt.Count) { return } $mb = [math]::Round((($alt | Measure-Object Length -Sum).Sum) / 1MB, 1) foreach ($f in $alt) { Remove-Item $f.FullName -Force -ErrorAction SilentlyContinue } if ($log) { & $log ("Aeltere Arbeitsdateien aus %APPDATA% entfernt ({0} Dateien, {1} MB)." -f $alt.Count, $mb) } } # =========================================================================== # Sprachdatei aus der Data.p4k holen # =========================================================================== # # Eine lose data\Localization\...\global.ini legt nicht das Spiel an, sondern # der SC-Deutsch-Launcher. Wer ihn nicht benutzt - und das sind die meisten - # hatte damit gar keine Quelle. Deshalb liest das Werkzeug notfalls direkt aus # der Data.p4k. # # Die p4k ist ein ZIP64-Archiv mit 1,37 Millionen Eintraegen. Die Texte liegen # darin ZStandard-komprimiert (Methode 100). Weder .NET noch das bsdtar von # Windows kommen da heran, deshalb der eigene Entpacker weiter unten. function Lade-Zstd { if (-not ('QuestGuide.Zstd' -as [type])) { Add-Type -TypeDefinition $ZSTD_QUELLE -Language CSharp } } # Sucht global.ini im Zentralverzeichnis und liefert Lage und Groesse. function Finde-P4kEintrag($p4kPfad, [scriptblock]$log) { $fs = [System.IO.File]::Open($p4kPfad, 'Open', 'Read', 'Read') $br = New-Object System.IO.BinaryReader($fs) try { # --- Ende des Zentralverzeichnisses suchen $suchLen = [int][Math]::Min([int64]65557, $fs.Length) $fs.Position = $fs.Length - $suchLen $ende = $br.ReadBytes($suchLen) $eocd = -1 for ($i = $ende.Length - 22; $i -ge 0; $i--) { if ($ende[$i] -eq 0x50 -and $ende[$i+1] -eq 0x4B -and $ende[$i+2] -eq 0x05 -and $ende[$i+3] -eq 0x06) { $eocd = $i; break } } if ($eocd -lt 0) { throw 'Data.p4k: kein Archivende gefunden' } $lok = $eocd - 20 if ($lok -lt 0 -or $ende[$lok] -ne 0x50 -or $ende[$lok+1] -ne 0x4B -or $ende[$lok+2] -ne 0x06 -or $ende[$lok+3] -ne 0x07) { throw 'Data.p4k: kein ZIP64-Verweis' } $z64 = [BitConverter]::ToInt64($ende, $lok + 8) $fs.Position = $z64 + 40 # Groesse, dann Lage des Verzeichnisses $cdGroesse = $br.ReadUInt64() $cdOffset = $br.ReadUInt64() & $log ("Data.p4k: Verzeichnis mit {0:N0} Bytes wird durchsucht..." -f $cdGroesse) # --- Alle global.ini einsammeln, dann die beste Sprache waehlen $fs.Position = $cdOffset $gelesen = 0L $treffer = @{} while ($gelesen -lt $cdGroesse) { $sig = $br.ReadUInt32(); $gelesen += 4 if ($sig -ne 0x02014B50) { break } [void]$br.ReadUInt16(); [void]$br.ReadUInt16(); [void]$br.ReadUInt16() $methode = $br.ReadUInt16() [void]$br.ReadUInt32() [void]$br.ReadUInt32() # Pruefwert - CIG-eigen, nicht CRC-32 $csize = $br.ReadUInt32(); $usize = $br.ReadUInt32() $nlen = $br.ReadUInt16(); $elen = $br.ReadUInt16(); $klen = $br.ReadUInt16() [void]$br.ReadUInt16(); [void]$br.ReadUInt16(); [void]$br.ReadUInt32() $lokOff = $br.ReadUInt32(); $gelesen += 42 $name = [System.Text.Encoding]::UTF8.GetString($br.ReadBytes($nlen)); $gelesen += $nlen $extra = $br.ReadBytes($elen); $gelesen += $elen if ($klen) { [void]$br.ReadBytes($klen); $gelesen += $klen } $pfad = $name.Replace('\', '/') if (-not ($pfad -like 'Data/Localization/*/global.ini')) { continue } # ZIP64-Zusatzfeld aufloesen. Achtung: 0xFFFFFFFF ist in PowerShell # ein Int32 mit dem Wert -1, deshalb gegen [uint32]::MaxValue. $u = [uint64]$usize; $c = [uint64]$csize; $o = [uint64]$lokOff if ($elen -ge 4 -and [BitConverter]::ToUInt16($extra, 0) -eq 1) { $j = 4 if ($usize -eq ([uint32]::MaxValue)) { $u = [BitConverter]::ToUInt64($extra, $j); $j += 8 } if ($csize -eq ([uint32]::MaxValue)) { $c = [BitConverter]::ToUInt64($extra, $j); $j += 8 } if ($lokOff -eq ([uint32]::MaxValue)) { $o = [BitConverter]::ToUInt64($extra, $j); $j += 8 } } $sprache = $pfad.Split('/')[2] $treffer[$sprache] = [pscustomobject]@{ Sprache = $sprache; Methode = $methode; Roh = $c; Klar = $u; Offset = $o } } if (-not $treffer.Count) { throw 'Data.p4k: keine global.ini gefunden' } # Hier gilt eine ANDERE Reihenfolge als bei der losen Datei. # # Die lose german_(germany)\global.ini stammt vom SC-Deutsch-Launcher # und ist die vollstaendige englische Datei mit uebersetzten Eintraegen # - rund 90.000 Schluessel. CIGs eigene deutsche Fassung in der p4k ist # dagegen nur eine Teiluebersetzung: 22.993 Schluessel, 53 Missionen # statt 599. Vollstaendig ist dort allein Englisch. # # Deshalb: Englisch bevorzugen, sonst die groesste vorhandene Fassung. if ($treffer.ContainsKey('english')) { return $treffer['english'] } return ($treffer.Values | Sort-Object { [int64]$_.Klar } -Descending | Select-Object -First 1) } finally { $br.Dispose(); $fs.Dispose() } } # Holt die Datei heraus und legt sie neben der Konfiguration ab. function Hole-IniAusP4k($kanal, [scriptblock]$log) { $p4kPfad = Join-Path $kanal.Pfad 'Data.p4k' Lade-Zstd $e = Finde-P4kEintrag $p4kPfad $log & $log (" {0}: {1:N0} Bytes komprimiert, {2:N0} entpackt" -f $e.Sprache, $e.Roh, $e.Klar) if ($e.Methode -ne 100 -and $e.Methode -ne 0) { throw ("Data.p4k: unbekannte Komprimierung {0}" -f $e.Methode) } $fs = [System.IO.File]::Open($p4kPfad, 'Open', 'Read', 'Read') $br = New-Object System.IO.BinaryReader($fs) try { $fs.Position = [int64]$e.Offset if ($br.ReadUInt32() -ne 0x04034B50) { throw 'Data.p4k: lokaler Kopf unerwartet' } [void]$br.ReadUInt16(); [void]$br.ReadUInt16(); [void]$br.ReadUInt16() [void]$br.ReadUInt32(); [void]$br.ReadUInt32(); [void]$br.ReadUInt32(); [void]$br.ReadUInt32() $ln = $br.ReadUInt16(); $le = $br.ReadUInt16() [void]$br.ReadBytes($ln); [void]$br.ReadBytes($le) $roh = $br.ReadBytes([int]$e.Roh) } finally { $br.Dispose(); $fs.Dispose() } $klar = if ($e.Methode -eq 0) { $roh } else { [QuestGuide.Zstd]::Entpacke($roh, 0, $roh.Length, [int64]$e.Klar) } if ($klar.Length -ne [int64]$e.Klar) { throw ("Data.p4k: entpackt {0:N0} Bytes, erwartet {1:N0}" -f $klar.Length, $e.Klar) } if (-not (Test-Path $ARBEIT_DIR)) { New-Item -ItemType Directory -Path $ARBEIT_DIR -Force | Out-Null } $ziel = Join-Path $ARBEIT_DIR ("global.p4k.{0}.ini" -f $kanal.Name) [System.IO.File]::WriteAllBytes($ziel, $klar) & $log " entpackt nach $ziel" return $ziel } # =========================================================================== # Extraktion (gleiche Logik wie tools/extract-missions.ps1) # =========================================================================== function Extrahiere($kanal, [scriptblock]$log) { # Keine lose Sprachdatei? Dann aus der Data.p4k holen. Das dauert etwa # eine Viertelminute - fast alles davon geht fuer das Durchsuchen des # Archivverzeichnisses drauf. if (-not $kanal.Ini) { & $log 'Keine lose Sprachdatei - lese aus Data.p4k.' $kanal = $kanal.PSObject.Copy() $kanal.Ini = Hole-IniAusP4k $kanal $log } & $log "Quelle: $($kanal.Ini)" # Ab hier wird nur noch aus $quelle gelesen. Faellt die Pruefung negativ # aus, zeigt $quelle anschliessend auf die zurueckgerechnete Kopie. $quelle = $kanal.Ini $wiederhergst = $false # --- Schutzsperre: nie gegen eine gepatchte Installation $dir = Split-Path $kanal.Ini $blocker = @() foreach ($bak in 'global.ini.bp.bak.json', 'global.ini.strings.bak.json') { $bp = Join-Path $dir $bak if (-not (Test-Path $bp)) { continue } $roh = [System.IO.File]::ReadAllText($bp, [System.Text.Encoding]::UTF8) if ($roh -match '"[A-Za-z0-9_]*(_title|_desc|_obj_)') { $blocker += "Launcher-Sicherung '$bak' enthaelt Missions-Keys" } else { & $log "Hinweis: '$bak' vorhanden, betrifft aber keine Missionstexte." } } # Ganze Datei pruefen, nicht nur den Anfang: Missionstexte liegen ueber # alle rund 90.000 Zeilen verteilt. $ganz = [System.IO.File]::ReadAllText($kanal.Ini, [System.Text.Encoding]::UTF8) foreach ($m in ([ordered]@{ '# Region' = '#\s*Region:.*Danger\s+\d' '# Expected reputation' = '#\s*Expected reputation' '# Min. Reputation' = '#\s*Min\.\s*Reputation' '# Mission Cooldown' = '#\s*Mission Cooldown' '# Blueprint Drop Chance'= '#\s*Blueprint Drop Chance' 'POSSIBLE BLUEPRINTS' = 'POSSIBLE BLUEPRINTS FOR THIS MISSION' '[BP]-Marker' = '\[BP\]' 'Verweis auf scmdb.net' = 'scmdb\.net' }).GetEnumerator()) { $n = ([regex]::Matches($ganz, $m.Value)).Count if ($n -gt 0) { $blocker += "injizierte Stellen `"$($m.Key)`": $n" } } $ganz = $null # rund 11 MB, wird nicht mehr gebraucht if ($blocker.Count) { & $log 'Diese Installation ist von einem Drittanbieter-Launcher veraendert:' foreach ($b in $blocker) { & $log " - $b" } # Der Launcher sichert jeden Wert, bevor er ihn ersetzt. Daraus laesst # sich der Auslieferungszustand zurueckrechnen - dann wird nicht die # fremde Kuratierung importiert, sondern der CIG-Text. $rs = Join-Path $PSScriptRoot 'restore-original.ps1' if (-not (Test-Path $rs)) { throw "Gepatchte Installation, und 'restore-original.ps1' liegt nicht neben " + "dem Uploader - ohne das Skript laesst sich der Originaltext nicht " + "zurueckrechnen. Bitte den kompletten tools-Ordner verwenden." } & $log 'Rechne den Originaltext aus der Launcher-Sicherung zurueck...' if (-not (Test-Path $ARBEIT_DIR)) { New-Item -ItemType Directory -Path $ARBEIT_DIR -Force | Out-Null } $ziel = Join-Path $ARBEIT_DIR ("global.restored.$($kanal.Name).ini") try { $r = & $rs -IniPath $kanal.Ini -OutPath $ziel } catch { throw "Die Installation ist veraendert und laesst sich nicht zurueckrechnen: " + "$($_.Exception.Message) Bitte diesen Kanal ueber den RSI-Launcher " + "pruefen/reparieren oder einen anderen Kanal (z.B. PTU) hochladen." } & $log ("Zurueckgesetzt: $($r.Exakt) Werte exakt, $($r.UeberZusatz) ueber " + "Schluessel ohne Varianten-Zusatz.") & $log 'Ergebnis ist frei von fremden Inhalten - Extraktion laeuft damit weiter.' $quelle = $r.Ziel $wiederhergst = $true } # --- global.ini einlesen $map = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($line in [System.IO.File]::ReadAllLines($quelle, [System.Text.Encoding]::UTF8)) { $eq = $line.IndexOf('='); if ($eq -lt 1) { continue } $k = $line.Substring(0, $eq).Trim() $c = $k.IndexOf(','); if ($c -gt 0) { $k = $k.Substring(0, $c) } if (-not $map.ContainsKey($k)) { $map[$k] = $line.Substring($eq + 1).Trim() } } & $log "$($map.Count) Schluessel gelesen." $IC = [System.StringComparison]::OrdinalIgnoreCase $titles = @{}; $descs = @{}; $objs = @{} foreach ($k in $map.Keys) { if ($k.EndsWith('_title', $IC)) { $titles[$k.Substring(0, $k.Length - 6).ToLowerInvariant()] = $k; continue } if ($k.EndsWith('_desc', $IC)) { $descs[ $k.Substring(0, $k.Length - 5).ToLowerInvariant()] = $k; continue } $i = $k.IndexOf('_obj_', $IC) if ($i -gt 0) { $p = $k.Substring(0, $i).ToLowerInvariant() if (-not $objs.ContainsKey($p)) { $objs[$p] = New-Object System.Collections.ArrayList } [void]$objs[$p].Add($k) } } $sha256 = [System.Security.Cryptography.SHA256]::Create() $result = New-Object System.Collections.ArrayList $skipped = 0 foreach ($p in $titles.Keys) { $hasDesc = $descs.ContainsKey($p); $hasObj = $objs.ContainsKey($p) # "_title" nutzt das Spiel auch fuer Terminals und Menues - ohne # Beschreibung UND ohne Objectives ist es keine Mission. if (-not $hasDesc -and -not $hasObj) { $skipped++; continue } $title = $map[$titles[$p]] $desc = if ($hasDesc) { $map[$descs[$p]] } else { $null } $objectives = New-Object System.Collections.ArrayList $params = New-Object 'System.Collections.Generic.HashSet[string]' if ($hasObj) { foreach ($ok in ($objs[$p] | Sort-Object)) { $rest = $ok.Substring($ok.IndexOf('_obj_', $IC) + 5) $ord = 0; $variant = $rest.ToLowerInvariant() if ($rest -match '^(\d+)_.+_([A-Za-z]+)$') { $ord = [int]$Matches[1]; $variant = $Matches[2].ToLowerInvariant() } elseif ($rest -match '^(.*)_(\d+)([a-z]?)$') { $variant = $Matches[1].ToLowerInvariant(); $ord = [int]$Matches[2] } [void]$objectives.Add([ordered]@{ variant = $variant; ordinal = $ord; ini_key = $ok; text = $map[$ok] }) } } foreach ($t in @($title, $desc) + @($objectives | ForEach-Object { $_.text })) { if ($t) { foreach ($mm in [regex]::Matches($t, '~mission\(([^)]+)\)')) { [void]$params.Add($mm.Groups[1].Value) } } } $basis = ($title + "`n" + $desc + "`n" + (($objectives | ForEach-Object { $_.ini_key + '=' + $_.text }) -join "`n")) $hash = ($sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($basis)) | ForEach-Object { $_.ToString('x2') }) -join '' [void]$result.Add([ordered]@{ prefix = $p kind = $(if ($title -match '~mission\(') { 'prozedural' } else { 'fest' }) title = $title description = $desc content_hash = $hash has_bp_reward = [bool]($title -match '\[BP\]') is_wip = [bool]($title -match '\[WIP\]') params = @($params) objectives = @($objectives) }) } $sha256.Dispose() & $log "$($result.Count) Missionen erkannt, $skipped UI-Strings uebersprungen." # ---------------------------------------------------------------------- # Objectives wiederverwendbarer Missionsbausteine # ---------------------------------------------------------------------- # # CIGs Missionssystem setzt Auftraege aus Bausteinen zusammen. Ein # Walkthrough braucht deren Objectives genauso wie die der Mission - # im HUD stehen sie nebeneinander, ohne erkennbaren Unterschied. # # Zwei Familien, zwei Regeln. # # 1) Bausteine benennen ihre Objectives GENAU WIE MISSIONEN: # # CreateUplink_obj_short_01 = Install ~mission(item) At ~mission(Destination) # CreateUplink_obj_short_02 = Collect ~mission(item) # # Der einzige Unterschied ist, dass keine Mission diesen Stamm traegt. # Genau daran erkennen wir sie: ein "_obj_"-Stamm ohne eigene Mission. # (Missionen wie battaglia_rpt_createuplink_01 verwenden das Modul, # heissen aber anders - der Stamm "createuplink" bleibt verwaist.) # # 2) Daneben gibt es Texte ohne "_obj_": # # Battaglia_MeetAndTalk_Short = Talk To Battaglia In Levski # # Die sind nur brauchbar, wenn ihr Stamm zu einem Auftraggeber gehoert, # also mindestens eine Mission mit diesem Praefix existiert. Ohne diese # Bedingung kaeme die HUD-Beschriftung der dynamischen Kampfsysteme mit # (DefendShips_Escort, AcePilot_Loot, SP_JumpedScoutShips) - fuer einen # Walkthrough wertlos, und in der Auswahlliste nur im Weg. $missionsstaemme = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($m in $result) { [void]$missionsstaemme.Add($m.prefix) } $bausteine = New-Object System.Collections.ArrayList # --- Regel 1: verwaiste "_obj_"-Staemme ------------------------------- $regel1 = 0 foreach ($p in $objs.Keys) { if ($missionsstaemme.Contains($p)) { continue } # Nach laufender Nummer sammeln, damit Kurz- und Langform # zusammenfinden. Der Marker bleibt aussen vor. $nummern = @{} $anzeige = $null foreach ($ok in ($objs[$p] | Sort-Object)) { if (-not $anzeige) { $anzeige = $ok.Substring(0, $ok.IndexOf('_obj_', $IC)) } $rest = $ok.Substring($ok.IndexOf('_obj_', $IC) + 5) $ord = 0; $variant = $rest.ToLowerInvariant() if ($rest -match '^(\d+)_.+_([A-Za-z]+)$') { $ord = [int]$Matches[1]; $variant = $Matches[2].ToLowerInvariant() } elseif ($rest -match '^(.*)_(\d+)([a-z]?)$') { $variant = $Matches[1].ToLowerInvariant(); $ord = [int]$Matches[2] } if ($variant -ne 'short' -and $variant -ne 'long') { continue } if (-not $nummern.ContainsKey($ord)) { $nummern[$ord] = @{ kurz = $null; lang = $null } } if ($variant -eq 'short') { $nummern[$ord].kurz = $map[$ok] } else { $nummern[$ord].lang = $map[$ok] } } foreach ($ord in ($nummern.Keys | Sort-Object)) { # Fehlt die Kurzform, tritt die Langform ein. Anders als bei # Regel 2 ist das hier unbedenklich: Der Schluessel sagt bereits, # dass es ein Objective ist. $kurz = "$($nummern[$ord].kurz)".Trim() $lang = "$($nummern[$ord].lang)".Trim() if ($kurz -eq '') { $kurz = $lang } if ($kurz -eq '') { continue } [void]$bausteine.Add([ordered]@{ modul = $anzeige name = ('{0:00}' -f $ord) ini_key = ('{0}_obj_{1:00}' -f $anzeige, $ord) kurz = $kurz lang = $(if ($lang -eq '') { $null } else { $lang }) }) $regel1++ } } # --- Regel 2: Auftraggeber-Texte ohne "_obj_" ------------------------- # Gehoert der Stamm zu einem Auftraggeber? Das ist der Fall, wenn eine # Mission genauso heisst oder mit "_" beginnt. $auftraggeber = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($s in $missionsstaemme) { $teile = $s.Split('_') for ($i = 1; $i -le $teile.Count; $i++) { [void]$auftraggeber.Add(($teile[0..($i-1)] -join '_')) } } $module = @{} foreach ($k in $map.Keys) { if ($k.IndexOf('_obj_', $IC) -ge 0) { continue } if ($k -notmatch '^([A-Za-z0-9]+)_([A-Za-z0-9]+)_(Short|Long|TTI)$') { continue } if (-not $auftraggeber.Contains($Matches[1].ToLowerInvariant())) { continue } $schluessel = ($Matches[1] + '_' + $Matches[2]) $form = $Matches[3].ToLowerInvariant() if (-not $module.ContainsKey($schluessel)) { $module[$schluessel] = [ordered]@{ modul = $Matches[1]; name = $Matches[2] ini_key = $schluessel; kurz = $null; lang = $null } } if ($form -eq 'short') { $module[$schluessel].kurz = $map[$k] } elseif ($form -eq 'long') { $module[$schluessel].lang = $map[$k] } # TTI ist die Beschriftung am Zielmarker - fuer einen Walkthrough # ueberfluessig, sie steht im Langtext ohnehin. } $regel2 = 0 foreach ($m in $module.Values) { if (-not $m.kurz -or -not $m.lang) { continue } $kurz = "$($m.kurz)".Trim() $lang = "$($m.lang)".Trim() if ($kurz -eq '' -or $lang -eq '') { continue } # Drei Muster, die durch die Doppelform rutschen, aber keine # Objectives sind: # - Kuerzel der Flugmodi (masterMode_NAV = "NAV", _SCM = "SCM") # - Beschriftungen mit Doppelpunkt (EscortShips_Health = "Ship Health:") # - reine Platzhalter (gtaa_token = "~mission(ObjectiveSetupShort)") if ($kurz.Length -le 4) { continue } if ($kurz.EndsWith(':')) { continue } if ($kurz -match '^~mission\([^)]*\)$') { continue } [void]$bausteine.Add($m) $regel2++ } & $log "$($bausteine.Count) Baustein-Objectives erkannt ($regel1 aus Modulen, $regel2 von Auftraggebern)." return [ordered]@{ build = [ordered]@{ build_ref = $kanal.Build channel = $kanal.Name # Lesbare Fassung neben der Buildnummer. Die Seite zeigt damit an, # fuer welche Spielversion ein Walkthrough geschrieben wurde - # "Alpha 4.10.0" erkennt jemand wieder, "12545750" nicht. game_version = $kanal.Version source_file = $quelle source_origin = $kanal.Ini # Aus der Launcher-Sicherung zurueckgerechnet statt direkt gelesen. # Der Text ist derselbe CIG-Text; der Vermerk haelt nur fest, auf # welchem Weg er zustande kam. restored = $wiederhergst source_sha256 = (Get-FileHash $quelle -Algorithm SHA256).Hash.ToLowerInvariant() extracted_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') mission_count = $result.Count module_count = $bausteine.Count # Nach erfolgreicher Rueckrechnung enthaelt die Quelle keine fremden # Inhalte mehr - sonst haette restore-original.ps1 abgebrochen. patched = ($blocker.Count -gt 0 -and -not $wiederhergst) } missions = @($result | Sort-Object prefix) # Wiederverwendbare Bausteine. Sie haengen an keiner Mission und # stehen deshalb neben der Missionsliste, nicht darin. modules = @($bausteine | Sort-Object ini_key) } } # =========================================================================== # Hochladen # =========================================================================== function Lade-Hoch($doc, $apiBase, $token, [scriptblock]$log) { $url = $apiBase.TrimEnd('/') + '/api/v1/import' if ($Force) { $url += '?force=1' } & $log "POST $url" $json = $doc | ConvertTo-Json -Depth 8 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) & $log ("Nutzlast: {0:N0} KB" -f ($bytes.Length / 1024)) try { $a = Invoke-RestMethod -Uri $url -Method Post -Body $bytes ` -ContentType 'application/json; charset=utf-8' ` -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 300 return $a } catch { # Die API antwortet bei Ablehnungen mit JSON - das ist wertvoller als # der blanke Statuscode, deshalb wird der Rumpf ausgelesen. $resp = $_.Exception.Response if ($resp) { $sr = New-Object System.IO.StreamReader($resp.GetResponseStream()) $rumpf = $sr.ReadToEnd(); $sr.Close() throw "HTTP $([int]$resp.StatusCode): $rumpf" } throw } } function Melde-Build($kanal, $apiBase, $token, [scriptblock]$log) { <# Meldet der Seite, welchen Spielstand diese Installation hat - ohne etwas hochzuladen. Warum ueberhaupt: Es gibt keine oeffentliche Schnittstelle von RSI, an der sich die aktuelle Build-Nummer ablesen liesse. Die Installation weiss es aber, und diese Meldung ist winzig. So sieht der Betreiber, dass ein neuer Stand draussen ist, bevor ihn jemand einspielt. Uebertragen werden nur Kanal und Build-Nummer. Ein Fehlschlag ist folgenlos - das eigentliche Hochladen haengt nicht daran. #> if (-not $token -or -not $apiBase) { return } try { $rumpf = @{ channel = $kanal.Name; build_ref = $kanal.Build } | ConvertTo-Json -Compress $a = Invoke-RestMethod -Uri ($apiBase.TrimEnd('/') + '/api/v1/build-ping') -Method Post ` -Body ([System.Text.Encoding]::UTF8.GetBytes($rumpf)) ` -ContentType 'application/json; charset=utf-8' ` -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 15 if ($a.bekannt) { & $log "Stand $($kanal.Build) ist bereits eingelesen." } else { & $log "Stand $($kanal.Build) fehlt der Seite noch - ein Upload hilft." } } catch { # Bewusst still: Die Meldung ist eine Nettigkeit, kein Arbeitsschritt. } } function Lauf($kanal, $apiBase, $token, [scriptblock]$log) { try { Lauf-Inner $kanal $apiBase $token $log } finally { # Auch nach einem Abbruch: Die ausgepackten Sprachdateien sind # Zwischenergebnisse und haben nichts zu suchen, wenn der Lauf vorbei ist. Raeume-Arbeit $log } } function Lauf-Inner($kanal, $apiBase, $token, [scriptblock]$log) { # Auch die Buildmeldung bleibt beim Trockenlauf aus. Sie ist harmlos, aber # "nichts gesendet" soll woertlich stimmen. if (-not $WhatIfOnly) { Melde-Build $kanal $apiBase $token $log } $doc = Extrahiere $kanal $log if ($WhatIfOnly) { & $log ("Trockenlauf: {0} Missionen aus Build {1} ({2}) - nichts gesendet." -f $doc.build.mission_count, $doc.build.build_ref, $doc.build.channel) & $log " Pruefsumme: $($doc.build.source_sha256.Substring(0,16))..." if ($doc.build.restored) { & $log ' Quelle wurde aus der Launcher-Sicherung zurueckgerechnet.' } return } $a = Lade-Hoch $doc $apiBase $token $log if ($a.unchanged) { & $log "Unveraendert - dieser Build ist bereits importiert (build_id $($a.build_id))." if ($null -ne $a.module) { & $log " $($a.module) wiederverwendbare Bausteine fortgeschrieben." } } else { & $log "Fertig. Build $($a.build_id), Kanal $($a.channel)." & $log (" neu {0} | aktualisiert {1} | unveraendert {2}" -f $a.missions.neu, $a.missions.aktualisiert, $a.missions.unveraendert) if ($a.module) { & $log " $($a.module) wiederverwendbare Bausteine fortgeschrieben." } if ($a.verschwunden) { & $log " $($a.verschwunden) Missionen aus dem Vorgaengerbuild fehlen jetzt." } if ($a.guides_zur_pruefung) { & $log " $($a.guides_zur_pruefung) Guides zur Pruefung markiert." } if ($a.workflows_zur_pruefung) { & $log " $($a.workflows_zur_pruefung) Workflows zur Pruefung markiert." } } return $a } # =========================================================================== # Kopfloser Lauf # =========================================================================== if ($NoGui) { $k = Lade-Konfig $apiBase = if ($ApiBase) { $ApiBase } else { $k.apiBase } $tok = if ($Token) { $Token } else { $k.token } $name = if ($Channel) { $Channel } else { $k.channel } $kanaele = Finde-Kanaele $kanal = $kanaele | Where-Object { $_.Name -eq $name } | Select-Object -First 1 if (-not $kanal) { throw "Kanal '$name' nicht gefunden. Verfuegbar: $(($kanaele.Name) -join ', ')" } # Ein halb geladener Kanal hat keine Sprachdatei. Ohne diese Pruefung # liefe die Extraktion in einen nichtssagenden Pfadfehler. if (-not $kanal.Bereit) { throw "Kanal '$name' ist noch nicht nutzbar: $($kanal.Grund)." } if (-not $tok) { throw 'Kein Token. Per -Token uebergeben oder einmal ueber die Oberflaeche speichern.' } $ausgabe = { param($m) Write-Host " $m" } Raeume-Altlasten $ausgabe Lauf $kanal $apiBase $tok $ausgabe | Out-Null return } # =========================================================================== # Oberflaeche # =========================================================================== Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() $konf = Lade-Konfig $kanaele = @(Finde-Kanaele) $dunkel = [System.Drawing.Color]::FromArgb(12, 19, 29) $hell = [System.Drawing.Color]::FromArgb(219, 230, 242) $akzent = [System.Drawing.Color]::FromArgb(57, 194, 255) $form = New-Object System.Windows.Forms.Form $form.Text = 'QuestGuide Uploader' $form.Size = New-Object System.Drawing.Size(640, 540) $form.StartPosition = 'CenterScreen' $form.BackColor = $dunkel $form.ForeColor = $hell $form.Font = New-Object System.Drawing.Font('Segoe UI', 9) function Beschriftung($text, $x, $y) { $l = New-Object System.Windows.Forms.Label $l.Text = $text; $l.Location = New-Object System.Drawing.Point($x, $y) $l.AutoSize = $true; $l.ForeColor = [System.Drawing.Color]::FromArgb(123, 142, 166) $form.Controls.Add($l); return $l } function Feld($x, $y, $w, $wert) { $t = New-Object System.Windows.Forms.TextBox $t.Location = New-Object System.Drawing.Point($x, $y) $t.Size = New-Object System.Drawing.Size($w, 22) $t.BackColor = [System.Drawing.Color]::FromArgb(5, 8, 13); $t.ForeColor = $hell $t.BorderStyle = 'FixedSingle'; $t.Text = $wert $form.Controls.Add($t); return $t } Beschriftung 'Game channel' 20 18 | Out-Null $cbKanal = New-Object System.Windows.Forms.ComboBox $cbKanal.Location = New-Object System.Drawing.Point(20, 38) $cbKanal.Size = New-Object System.Drawing.Size(280, 24) $cbKanal.DropDownStyle = 'DropDownList' $cbKanal.BackColor = [System.Drawing.Color]::FromArgb(5, 8, 13); $cbKanal.ForeColor = $hell # Fuellt die Auswahlliste. Als Funktion, weil 'Rescan' sie neu aufbaut. # # Unfertige Kanaele stehen mit in der Liste. Sie einfach wegzulassen war die # schlechtere Loesung: Im Launcher stand HOTFIX, hier nicht, und der Grund war # nirgends zu sehen. function Fuelle-Kanalliste($bevorzugt) { $cbKanal.Items.Clear() foreach ($k in $script:kanaele) { $text = if ($k.Bereit -and $k.AusP4k) { "$($k.Name) - Build $($k.Build) (aus Data.p4k)" } elseif ($k.Bereit) { "$($k.Name) - Build $($k.Build)" } elseif ($k.Build -ne 'unbekannt') { "$($k.Name) - Build $($k.Build), $($k.Grund)" } else { "$($k.Name) - $($k.Grund)" } [void]$cbKanal.Items.Add($text) } if (-not $cbKanal.Items.Count) { return } # Auf einen nutzbaren Kanal vorwaehlen, nicht auf den ersten besten. $i = ($script:kanaele.Name).IndexOf($bevorzugt) if ($i -lt 0 -or -not $script:kanaele[$i].Bereit) { $i = [array]::FindIndex([object[]]$script:kanaele, [Predicate[object]]{ param($x) $x.Bereit }) } $cbKanal.SelectedIndex = [Math]::Max(0, $i) } Fuelle-Kanalliste $konf.channel $form.Controls.Add($cbKanal) # Neu einlesen, ohne das Werkzeug zu schliessen. Gebraucht, sobald der # Launcher waehrend der Sitzung fertig laedt oder einen Kanal auspackt - # vorher half nur ein Neustart. $btnNeu = New-Object System.Windows.Forms.Button $btnNeu.Text = 'Rescan' $btnNeu.Location = New-Object System.Drawing.Point(308, 37) $btnNeu.Size = New-Object System.Drawing.Size(76, 26) $btnNeu.FlatStyle = 'Flat' $btnNeu.FlatAppearance.BorderColor = $akzent $btnNeu.ForeColor = $akzent $btnNeu.BackColor = $dunkel $form.Controls.Add($btnNeu) $lblInfo = Beschriftung '' 396 42 if (-not $kanaele.Count) { $lblInfo.Text = 'Keine Installation gefunden' $lblInfo.ForeColor = [System.Drawing.Color]::FromArgb(255, 95, 107) } Beschriftung 'API address' 20 76 | Out-Null $txtApi = Feld 20 96 380 $konf.apiBase Beschriftung 'Import token' 20 130 | Out-Null $txtTok = Feld 20 150 380 $konf.token $txtTok.UseSystemPasswordChar = $true $chkZeigen = New-Object System.Windows.Forms.CheckBox $chkZeigen.Text = 'show'; $chkZeigen.Location = New-Object System.Drawing.Point(410, 150) $chkZeigen.AutoSize = $true; $chkZeigen.ForeColor = [System.Drawing.Color]::FromArgb(123, 142, 166) $chkZeigen.Add_CheckedChanged({ $txtTok.UseSystemPasswordChar = -not $chkZeigen.Checked }) $form.Controls.Add($chkZeigen) function Knopf($text, $x, $y, $w) { $b = New-Object System.Windows.Forms.Button $b.Text = $text; $b.Location = New-Object System.Drawing.Point($x, $y) $b.Size = New-Object System.Drawing.Size($w, 32); $b.FlatStyle = 'Flat' $b.FlatAppearance.BorderColor = $akzent; $b.ForeColor = $akzent $b.BackColor = $dunkel $form.Controls.Add($b); return $b } $btnPruefen = Knopf 'Test connection' 20 192 150 $btnLauf = Knopf 'Extract and upload' 180 192 170 $btnSpeichern = Knopf 'Save settings' 360 192 130 $txtLog = New-Object System.Windows.Forms.TextBox $txtLog.Location = New-Object System.Drawing.Point(20, 240) $txtLog.Size = New-Object System.Drawing.Size(580, 240) $txtLog.Multiline = $true; $txtLog.ScrollBars = 'Vertical'; $txtLog.ReadOnly = $true $txtLog.BackColor = [System.Drawing.Color]::FromArgb(5, 8, 13); $txtLog.ForeColor = $hell $txtLog.Font = New-Object System.Drawing.Font('Consolas', 9) $form.Controls.Add($txtLog) $schreib = { param($m) $txtLog.AppendText((Get-Date -Format 'HH:mm:ss') + ' ' + $m + [Environment]::NewLine) [System.Windows.Forms.Application]::DoEvents() } function AktuellerKanal { if ($cbKanal.SelectedIndex -ge 0) { return $kanaele[$cbKanal.SelectedIndex] } return $null } $btnSpeichern.Add_Click({ $k = [pscustomobject]@{ apiBase = $txtApi.Text.Trim() token = $txtTok.Text.Trim() channel = if (AktuellerKanal) { (AktuellerKanal).Name } else { 'LIVE' } } Speichere-Konfig $k & $schreib "Einstellungen gespeichert: $CONF_FILE" }) $btnPruefen.Add_Click({ try { $url = $txtApi.Text.Trim().TrimEnd('/') + '/api/v1/builds' & $schreib "GET $url" $a = Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Bearer $($txtTok.Text.Trim())" } -TimeoutSec 30 if (-not $a -or $a.Count -eq 0) { & $schreib 'Verbindung steht. Noch keine Builds importiert.' } else { & $schreib "Verbindung steht. Bekannte Builds:" foreach ($b in $a) { & $schreib (" {0,-8} {1,-12} {2,5} Missionen {3}" -f $b.channel, $b.build_ref, $b.mission_count, $b.imported_at) } } } catch { & $schreib "FEHLER: $($_.Exception.Message)" } }) $btnLauf.Add_Click({ $kanal = AktuellerKanal if (-not $kanal) { & $schreib 'Kein Kanal gewaehlt.'; return } if (-not $kanal.Bereit) { & $schreib "$($kanal.Name) ist noch nicht nutzbar: $($kanal.Grund)." & $schreib 'Sobald der Launcher fertig geladen hat, erscheint der Kanal hier normal.' return } $btnLauf.Enabled = $false try { & $schreib "--- $($kanal.Name), Build $($kanal.Build) ---" Lauf $kanal $txtApi.Text.Trim() $txtTok.Text.Trim() $schreib | Out-Null } catch { & $schreib "FEHLER: $($_.Exception.Message)" } finally { $btnLauf.Enabled = $true } }) function Zeige-Kanaele { if (-not $script:kanaele.Count) { & $schreib 'Keine Installation gefunden.' return } & $schreib 'Gefundene Installationen:' foreach ($k in $script:kanaele) { & $schreib (" {0,-14} {1}" -f $k.Name, $(if ($k.Bereit -and $k.AusP4k) { "Build $($k.Build) (aus Data.p4k)" } elseif ($k.Bereit) { "Build $($k.Build) ($($k.Sprache))" } else { $k.Grund })) } } $btnNeu.Add_Click({ $vorher = if (AktuellerKanal) { (AktuellerKanal).Name } else { $konf.channel } $script:kanaele = @(Finde-Kanaele) Fuelle-Kanalliste $vorher $lblInfo.Text = '' if (-not $script:kanaele.Count) { $lblInfo.Text = 'Keine Installation gefunden' $lblInfo.ForeColor = [System.Drawing.Color]::FromArgb(255, 95, 107) } & $schreib '--- neu eingelesen ---' Zeige-Kanaele }) Zeige-Kanaele Raeume-Altlasten $schreib Raeume-Arbeit $null # Reste eines abgebrochenen Laufs, still [void]$form.ShowDialog()