# FF14 漢化筆記 此程式現於Github開源。 1. [PatchTool 修改版](https://github.com/GpointChen/FFXIVChnTextPatch-GP/) 2. [SaintCoinach 修改版](https://github.com/GpointChen/SaintCoinach/tree/hexcode) ## 1 背景知識 1. [FFXIV資料格式](https://xiv.dev/game-data/file-formats/excel) 2. Java 3. C# ## 2 FFXIVChnTextPatch 1. 從Github下載[FFXIVChnTextPatch](https://github.com/reusu/FFXIVChnTextPatch) 2. ~~安裝Java SDK(我是用Java OpenJDK 16.0.1)~~ 3. 安裝Java的IDE(我是用Eclipse 2021-06版本) 4. 安裝Dependencies,兩種選擇: 1. 專案瀏覽器中右鍵點擊資料夾,內容>Java建置路徑,在類別路徑選擇新增外部jar。所需要的jar列表可以在gradle找到。 2. 也可以考慮將專案轉換成maven project,可以在pom.xml中設定dependencies。 ### 2.1 Java Decompiler [Java Decompiler](http://java-decompiler.github.io/) is used to get the source codes of latest version. 我們將decompile的檔案拆包,覆蓋原本的檔案(`FFXIVChnTextPatch\src\main\java\`)。 清除專案重新build,發現無法執行。用和前面一樣的方法新增`commons-logging-1.2`這個jar,運行成功。 ### 2.2 修改程式碼 在做任何修改之前,實際先測試build一遍,發現會跳以下錯誤 ``` java.nio.BufferUnderflowException at java.base/java.nio.HeapByteBuffer.get(HeapByteBuffer.java:183) at java.base/java.nio.ByteBuffer.get(ByteBuffer.java:822) at name.yumao.ffxiv.chn.model.EXDFEntry.getString(EXDFEntry.java:83) at name.yumao.ffxiv.chn.replace.ReplaceEXDF.replace(ReplaceEXDF.java:178) at name.yumao.ffxiv.chn.thread.ReplaceThread.run(ReplaceThread.java:42) at java.base/java.lang.Thread.run(Thread.java:831) ``` 這是因為我們反編譯的檔案少了一串括號。修改EXDFEntry.java: ```java=85 // nullTermPos = buffer.position() - datasetChunkSize + stringOffset; nullTermPos = buffer.position() - (datasetChunkSize + stringOffset); ``` 接著又遇到這個問題: ``` Now File : EXD/AchievementHideCondition.EXH Replace File : AchievementHideCondition java.lang.NullPointerException: Cannot read the array length because "array" is null at java.base/java.nio.ByteBuffer.wrap(ByteBuffer.java:435) at name.yumao.ffxiv.chn.model.EXDFFile.loadEXDF(EXDFFile.java:32) at name.yumao.ffxiv.chn.model.EXDFFile.<init>(EXDFFile.java:18) at name.yumao.ffxiv.chn.replace.ReplaceEXDF.replace(ReplaceEXDF.java:139) at name.yumao.ffxiv.chn.thread.ReplaceThread.run(ReplaceThread.java:38) at java.base/java.lang.Thread.run(Thread.java:831) ``` 修改ReplaceEXDF.java,新增`continue;` ```java=136 try { exdFileJA = extractFile(this.pathToIndexSE, exdIndexFileJA.getOffset()); } catch (Exception jaEXDFileException) { continue; } ``` 這樣就可以正常運行了。 類似的結構在EXDFUtil.java的210行、272行等地方也有出現。如果想防止意外,也可以都做修改。建議搜尋` catch (Exception jaEXDFileException) `。 ### 2.3 修改:吃國際版漢化過的檔案 參考[這篇巴哈小屋文章](https://home.gamer.com.tw/artwork.php?sn=5050986)。 為了方便能同時吃未漢化和漢化過的檔案,我們將抽取處也設定進config裡面。 要修改以下檔案 * ConfigApplicationPanel.java * EXDFUtil.java * ReplaceEXDF.java ### 2.4 修改:指針 修正指針不會跑的問題,如下。 ReplaceEXDF.java ```java=105 // edited // this.percentPanel.percentShow(++fileCount / this.fileList.size()); percentPanel.percentShow((double)(++fileCount) / (double)fileList.size()); ``` ### 2.5 移除提摩院更新 FFXIVPatchMain.java移除以下部分 ```java=21 PercentPanel percentPanel = new PercentPanel("提莫苑|获取资源"); try { String index = Request.Get("http://ffxiv.chn.teemo.name/index").connectTimeout(1000).socketTimeout(1000).execute().returnContent().asString(); // System.out.println("index: " + index); Type listType = (new TypeToken<ArrayList<TeemoUpdateVo>>() { }).getType(); updates = (List)(new Gson()).fromJson(index, listType); } catch (Exception exception) { System.out.println("Exception caught in Request get!"); } finally { percentPanel.dispose(); } ``` 修改以下檔案中和update有關的部分: * ConfigApplicationPanel.java * ReplaceEXDF.java * ReplaceThread.java * RollbackThread.java * TextPatchPanel.java 將與`TeemoUpdateVo`和`update`有關的行列刪掉或註解掉,然後依序檢查跳錯誤的地方,最後將import去除。 另外,ConfigApplicationPanel.java中的資源版本項也可以一併註解掉。 ```java=58 /* private JLabel transModeLable = new JLabel("资源版本"); private JComboBox<String> transModeVal; */ ``` ### 2.6 EXD/QUEST.EXH 任務名稱還是原文沒有翻譯。推測是EXD/QUEST.EXH沒有被正確處理。 進入EXDFUtil.java檢查,最後鎖定問題在於`public HashMap<String, byte[]> exQuestCN(HashMap<String, byte[]> exMap)`沒有將任何東西塞進`questMap`。而會發生這樣的事情是因為沒有讀到任何的「KEY」,例如`SubFst010_00001`這樣一串符號。 透過Godbert,我們發現key的Offset從0x968變成了0x96C。因此,我們修改第二個if處的offset條件: ```java=269 if (exdfDatasetSE.type == 0x0 && exdfDatasetSE.offset == 0x96C /* it was 0x968 */) { key = new String(exdfEntryJA.getString(exdfDatasetSE.offset), "UTF-8"); // System.out.println("\t\t\tGet Key! " + String.valueOf(key)); } ``` ```java=341 if (exdfDatasetSE.type == 0x0 && exdfDatasetSE.offset == 0x96C /* it was 0x968 */) { String key = new String(exdfEntryJA.getString(exdfDatasetSE.offset), "UTF-8"); if (sourceMap.get(key) != null) exMap.put(("EXD/Quest_".toLowerCase() + String.valueOf(listEntryIndex) + "_1").toLowerCase(), sourceMap.get(key)); break; } ``` ### 2.7 讀取CSV 讀取CSV的功能我使用了[Univocity Parser](https://www.univocity.com/pages/univocity_parsers_tutorial),請記得在ReplaceEXDF.java加上import。 ConfigApplicationPanel.java ```java=152 // added // 因為新增了這一段,以下各物件的setBounds的第二項都要增加30(往下移)。 // 為了因應CSV,新增CSV選項。 this.fLangLable.setBounds(30, 70, 100, 25); this.fLangLable.setFont(new Font("Microsoft Yahei", 1, 13)); this.fLangLable.setForeground(new Color(110, 110, 110)); add(this.fLangLable, 0); this.fLangLableVal = new JComboBox<>(); this.fLangLableVal.addItem("CSV"); this.fLangLableVal.addItem("日文"); this.fLangLableVal.addItem("英文"); this.fLangLableVal.addItem("德文"); this.fLangLableVal.addItem("法文"); this.fLangLableVal.addItem("簡體中文"); this.fLangLableVal.setBounds(100, 70, 160, 23); this.fLangLableVal.setFont(new Font("Microsoft Yahei", 1, 13)); this.fLangLableVal.setForeground(new Color(110, 110, 110)); this.fLangLableVal.setOpaque(false); this.fLangLableVal.setFocusable(false); add(this.fLangLableVal, 0); ``` Language.java ```java=3 public enum Language { CHS("簡體中文", "CHS", "chs", "5"), CHT("正體中文", "CHT", "cht", "5"), CSV("CSV", "CSV", "csv", "6"), JA("日文", "JA", "ja", "0"), EN("英文", "EN", "en-gb", "1"), DE("德文", "DE", "de", "2"), FR("法文", "FR", "fr", "3"); ``` ReplaceEXDF.java 在以下位置新增CSV用的內容。 7/21修改:確定FileReader編碼以避免後續輸出exe時出現編碼問題。 ```java=162 if ((exhSE.getLangs()).length > 0) { // added for CSV HashMap<Integer, Integer> offsetMap = new HashMap<>(); // ArrayList<List<String>> dataList = new ArrayList<List<String>>(); HashMap<Integer, String[]> csvDataMap = new HashMap<>(); if (this.csv) { try { CsvParserSettings csvSettings = new CsvParserSettings(); csvSettings.setMaxCharsPerColumn(-1); csvSettings.setMaxColumns(4096); CsvParser csvParser = new CsvParser(csvSettings); String csvPath = "resource" + File.separator + "rawexd" + File.separator + replaceFile.substring(4, replaceFile.indexOf(".")) + ".csv"; if (new File(csvPath).exists()) { List<String[]> allRows = csvParser.parseAll(new FileReader(csvPath, StandardCharsets.UTF_8)); for (int i = 1; i < allRows.get(1).length; i++) { offsetMap.put(Integer.valueOf((allRows.get(1))[i]), i - 1); } int rowNumber = allRows.size(); for (int i = 3; i < rowNumber; i++) { csvDataMap.put(Integer.valueOf((allRows.get(i))[0]), Arrays.copyOfRange(allRows.get(i), 1, allRows.get(i).length)); } } else { System.out.println("\t\tCSV file not exists! " + csvPath); continue; } } catch (Exception csvFileIndexValueException) { System.out.println("\t\tCSV Exception. " + csvFileIndexValueException.getMessage()); continue; } } ``` ```java=269 // 更新文本內容 // EXD/warp/WarpInnUldah.EXH -> exd/warp/warpinnuldah_xxxxx_xxxxx String transKey = replaceFile.substring(0, replaceFile.lastIndexOf(".")).toLowerCase() + "_" + String.valueOf(listEntryIndex) + "_" + String.valueOf(stringCount); if (this.csv) { // added CSV mode // need a name like quest or quest/000/ClsHrv001_00003 (replaceFile) // need an offset (exdfDatasetSE.offset) Integer offsetInteger = offsetMap.get(Integer.valueOf(exdfDatasetSE.offset)); String[] rowStrings = csvDataMap.get(listEntryIndex); if (rowStrings != null) { String readString = rowStrings[offsetInteger]; String newString = new String(); boolean isHexString = false; if (readString != null) { for (int i = 0; i < readString.length(); i++) { char currentChar = readString.charAt(i); switch (currentChar) { case '<': { if ((readString.charAt(i+1) == 'h') && (readString.charAt(i+2) == 'e') && (readString.charAt(i+3) == 'x')) { if (isHexString) { throw new Exception("TagInTagException!" + readString); } else { isHexString = true; } } if (newString.length() > 0) { newFFXIVString = ArrayUtil.append(newFFXIVString, newString.getBytes("UTF-8")); newString = ""; } newString += currentChar; break; } case '>': { newString += currentChar; if (isHexString) { newFFXIVString = ArrayUtil.append(newFFXIVString, HexUtils.hexStringToBytes(newString.substring(5, newString.length() - 1))); newString = ""; isHexString = false; } break; } default: { newString += currentChar; } } } if (newString.length() > 0) { newFFXIVString = ArrayUtil.append(newFFXIVString, newString.getBytes("UTF-8")); newString = ""; } } else { System.out.println("\t\tCannot find listEntryIndex " + String.valueOf(listEntryIndex)); newFFXIVString = ArrayUtil.append(newFFXIVString, jaBytes); } } // added end ^ } else if (Config.getConfigResource("transtable") != null && Config.getProperty("transtable", transKey) != null && Config.getProperty("transtable", transKey).length() > 0) { ``` 如果想要,前面也可以作相對應修改,讓我們可以不用在`common/text`資料夾下放陸版檔案。 ```java=153 boolean cnEXHFileAvailable = true; if (!this.csv) { try { SqPackIndexFile exhIndexFileCN = (SqPackIndexFile)((SqPackIndexFolder)indexCN.get(filePatchCRC)).getFiles().get(exhFileCRC); byte[] exhFileCN = extractFile(this.pathToIndexCN, exhIndexFileCN.getOffset()); exhCN = new EXHFFile(exhFileCN); // 添加對照的StringDataset int cnDatasetPossition = 0; if (datasetStringCount(exhSE.getDatasets()) > 0 && datasetStringCount(exhSE.getDatasets()) == datasetStringCount(exhCN.getDatasets())) for (EXDFDataset datasetSE : exhSE.getDatasets()) { if (datasetSE.type == 0) { while ((exhCN.getDatasets()[cnDatasetPossition]).type != 0) cnDatasetPossition++; datasetMap.put(datasetSE, exhCN.getDatasets()[cnDatasetPossition++]); } } } catch (Exception cnEXHFileException) { cnEXHFileAvailable = false; } } else { cnEXHFileAvailable = false; } if ((exhSE.getLangs()).length > 0) { // added for CSV HashMap<Integer, Integer> offsetMap = new HashMap<>(); // ArrayList<List<String>> dataList = new ArrayList<List<String>>(); HashMap<Integer, String[]> csvDataMap = new HashMap<>(); if (this.csv) { try { CsvParserSettings csvSettings = new CsvParserSettings(); csvSettings.setMaxCharsPerColumn(-1); csvSettings.setMaxColumns(4096); CsvParser csvParser = new CsvParser(csvSettings); String csvPath = "resource" + File.separator + "rawexd" + File.separator + replaceFile.substring(4, replaceFile.indexOf(".")) + ".csv"; if (new File(csvPath).exists()) { List<String[]> allRows = csvParser.parseAll(new FileReader(csvPath)); for (int i = 1; i < allRows.get(1).length; i++) { offsetMap.put(Integer.valueOf((allRows.get(1))[i]), i - 1); } int rowNumber = allRows.size(); for (int i = 3; i < rowNumber; i++) { csvDataMap.put(Integer.valueOf((allRows.get(i))[0]), Arrays.copyOfRange(allRows.get(i), 1, allRows.get(i).length)); } } else { System.out.println("\t\tCSV file not exists! " + csvPath); continue; } } catch (Exception csvFileIndexValueException) { System.out.println("\t\tCSV Exception. " + csvFileIndexValueException.getMessage()); continue; } } ``` 然後是ReplaceThread.java ```java=39 if ((new File("resource" + File.separator + "text" + File.separator + "0a0000.win32.index")).exists()) { (new ReplaceEXDF(this.resourceFolder + File.separator + "0a0000.win32.index", "resource" + File.separator + "text" + File.separator + "0a0000.win32.index", percentPanel)).replace(); } else if ((new File("resource" + File.separator + "rawexd" + File.separator + "Achievement.csv")).exists()) { (new ReplaceEXDF(this.resourceFolder + File.separator + "0a0000.win32.index", "resource" + File.separator + "rawexd" + File.separator + "Achievement.csv", percentPanel)).replace(); } else { System.out.println("No resource files detected!"); } ``` ### 2.8 字型問題 在我們可以選擇用已漢化的包作為放進`resource/text/`的檔案後,發現這樣做的時候字體包會不太一樣,導致某些圖像類的英數字區塊會出現亂碼。 ![](https://i.imgur.com/W6SchPf.png) ~~因為ReplaceFont和ReplaceEXDF是處理不同的檔案,目前先用手上的漢化覆蓋檔的`000000.win32....`來直接覆蓋遊戲檔案,可以解決。~~ 如果手上有覆蓋漢化檔,可以用以下方式解決。 ### 2.8.1 拆包替換 使用[FFXIV Explorer](http://ffxivexplorer.fragmenterworks.com/)查看漢化覆蓋檔的000000.win32.dat0裡面的檔案,然後和提摩院的漢化`resource/font`資料夾內的檔案做比較。 除了完全一樣的檔案外,部分檔案在FFXIV Explorer無法正確顯示。透過比較binary內容,確定以下檔案對應: | 漢化包檔案 | 覆蓋檔FFXIV Explorer拆包檔案 | | -------- | -------- | | axis_12.fdt | ~a9b7b1a2 | | axis_14.fdt | ~26f74402 | | axis_18.fdt | ~e307a903 | | axis_36.fdt | ~11ffb669 | | axis_96.fdt | ~b064950f | | miedingermid_10.fdt | ~7b3aa512 | | miedingermid_12.fdt | ~1faf672 | | miedingermid_14.fdt | ~8eba03d2 | | miedingermid_18.fdt | ~4b4aeed3 | | trumpgothic_184.fdt | ~ffa087be | 除此之外,以下檔案與原版檔案不同,且漢化包中沒有。我們猜測這些檔案和其他檔案一樣,都是font + number + .fdt的格式,所以手猜猜出了四個,最後一個是用程式窮舉直到找出結果為止。將這些檔案改名後放到`resource/font`裡面,就可以解決字體問題。 | 覆蓋檔FFXIV Explorer拆包檔案 | 嘗試結果 | | -------- | -------- | | 73CDF66F | meidinger_40.fdt | | B9B3F2B9 | miedingermid_36.fdt | | D2024114 | jupiter_90.fdt | | DC44E770 | trumpgothic_68.fdt | | E1DCA76A | jupiter_46.fdt | ### 2.9 指令碼處理 可參考[這篇](https://home.gamer.com.tw/artwork.php?sn=5064530)。例如最基礎的換行指令碼(`0x02100103`)基本的結構是: | 02 | 10 | 01 | 03 | | - | - | - | - | | 指令碼開頭 | 指令碼類型 | 指令碼長度 | 指令碼結尾 | * 指令碼類型可以參考SaintCoinach的`TagType.cs` * 指令碼長度$n$是指接下來的$n - 1$的byte屬於指令內,不含指令碼結尾,例如`0x01`代表$1-1=0$ * 特殊指令碼長度可以參考SaintCoinach的`XIVStringDecoder.cs`的`protected static int GetInteger(BinaryReader input, List<byte> lenByte)`等函式 * `IntegerType.cs` * 指令碼中的特殊指令碼(例如If判斷句)會以`0xFF`開頭,後面接一個長度,格式與上類似。 * `XIVStringDecoder.cs`的`protected INode DecodeExpression(BinaryReader input)` * `DecodeExpressionType.cs` * 其他關於SaintCoinach的實作和修改列於後面章節。 另一個例子: | 02 | 48 | 04 | F20215 | 03 | | - | - | - | - | - | | 指令碼開頭 | 指令碼類型<br />(UI前景) | 長度<br />3 bytes | 指令內容<br />UI前景類型 | 指令碼結尾 | 更複雜的例子: ``` 020851E4E80201FF25024804F2021E03024904F2021F03e78db5e99abce999a3e7879f02490201030248020103FF25024804F2022003024904F2022103e6b8a1e9b489e999a3e7879f0249020103024802010303 <hex:020851E4E80201FF25><hex:024804F2021E03><hex:024904F2021F03>獵隼陣營<hex:0249020103><hex:0248020103><hex:FF25><hex:024804F2022003><hex:024904F2022103>渡鴉陣營<hex:0249020103><hex:0248020103><hex:03> ``` switch case: `<hex:020957E802FF10E38199E381B9E381A6E8A1A8E7A4BAFF1FE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BAFF22E69CAAE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA03>` `<Switch(IntegerParameter(1))><Case(1)>顯示全部</Case><Case(2)>只顯示已完成</Case><Case(3)>只顯示未完成</Case></Switch>` * `02` 指令開頭 * `09` 指令類型:Switch * `57` 指令長度 * `FF` 指令段落點,下一個byte`10`是段落長度 * `E38199E381B9E381A6E8A1A8E7A4BA` 內文,UTF-8為`すべて表示` * `FF1F` 前byte為指令段落點,後byte為段落長度 * `E382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA` 內文,UTF-8為`コンプリートのみ表示` * `FF22` * `E69CAAE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA` 內文,UTF-8為`未コンプリートのみ表示` * `03` 指令結尾 ## 3 SaintCoinach 1. 安裝Visual Studio 2. 匯入專案 3. 工具 > Nuget > 套件管理器主控台 4. 輸入 `Update-Package -reinstall` [參考資料來源](https://docs.microsoft.com/zh-tw/nuget/consume-packages/reinstalling-and-updating-packages) ### 3.1 讓SaintCoinach可以輸出Offset 為了讓我們可以在csv直接讀到各個column的offset,我們需要讓其輸出offset資訊。我們修改`SaintCoinach.Cmd\ExdHelper.cs`: ```csharp=16 public static void SaveAsCsv(Ex.Relational.IRelationalSheet sheet, Language language, string path, bool writeRaw) { using (var s = new StreamWriter(path, false, Encoding.UTF8)) { var indexLine = new StringBuilder("key"); var nameLine = new StringBuilder("#"); var offsetLine = new StringBuilder("offset"); // added var typeLine = new StringBuilder("int32"); var colIndices = new List<int>(); foreach (var col in sheet.Header.Columns) { indexLine.AppendFormat(",{0}", col.Index); nameLine.AppendFormat(",{0}", col.Name); offsetLine.AppendFormat(",{0}", col.Offset); // added typeLine.AppendFormat(",{0}", col.ValueType); colIndices.Add(col.Index); } s.WriteLine(indexLine); s.WriteLine(nameLine); s.WriteLine(offsetLine); // added s.WriteLine(typeLine); ExdHelper.WriteRows(s, sheet, language, colIndices, writeRaw); } } ``` ### 3.2 修改輸出格式 雖然我們也可以像FFXIVChnTextPatch一樣將包在指令碼裡面的一般文字全部以hex呈現,但既然SaintCoinach做了更精細的分類,那麼沒道理不用。 首先從`XivStringDecoder.cs`開始。 在SC做decode的過程中,會將代表指令長度的bytes捨去或轉換成int做處理。我們為所有Decoder增加第四個引數lengthByteStr,讓他能以hex的形式輸出這個長度。 ```csharp=11 public class XivStringDecoder { public delegate INode TagDecoder(BinaryReader input, TagType tag, int length, String lengthByteStr); ``` ```csharp=53 #region Constructor public XivStringDecoder() { this.DefaultTagDecoder = DecodeTagDefault; SetDecoder(TagType.Clickable, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 1, int.MaxValue)); // I have no idea. SetDecoder(TagType.Color, DecodeColor); SetDecoder(TagType.CommandIcon, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.Dash, (i, t, l, h) => new Nodes.StaticString(this.Dash)); SetDecoder(TagType.Emphasis, DecodeGenericSurroundingTag); SetDecoder(TagType.Emphasis2, DecodeGenericSurroundingTag); // TODO: Fixed SetDecoder(TagType.Format, DecodeFormat); SetDecoder(TagType.Gui, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.Highlight, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.If, DecodeIf); SetDecoder(TagType.IfEquals, DecodeIfEquals); // Indent SetDecoder(TagType.InstanceContent, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.LineBreak, (i, t, l, h) => new Nodes.StaticString("<hex:02100103>")); SetDecoder(TagType.Sheet, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 2, int.MaxValue)); // Sheet name, Row[, Column[, Parameters]+] SetDecoder(TagType.SheetDe, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetEn, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetFr, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetJa, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.Split, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 3, false)); // Input expression, Seperator, Index to use SetDecoder(TagType.Switch, DecodeSwitch); SetDecoder(TagType.Time, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.TwoDigitValue, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); // Unknowns SetDecoder(TagType.Value, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.ZeroPaddedValue, DecodeZeroPaddedValue); } ``` 修改主要Decode函式的規則如下。注意第二層Decode()也會被指令碼出現`0xFF`時呼叫,所以同樣新增`lenByte`──而且在這種情況下,`lenByte`會含有`0xFF`以及代表長度的byte(s)。 ```csharp=97 #region Decode public XivString Decode(byte[] buffer) { using (var ms = new MemoryStream(buffer)) { using (var r = new BinaryReader(ms, this.Encoding)) return Decode(r, buffer.Length, new List<byte> { } ); } } public XivString Decode(BinaryReader input, int length, List<byte> lenByte) { // check input size if (length < 0) throw new ArgumentOutOfRangeException("length"); // set the end of the input var end = input.BaseStream.Position + length; if (end > input.BaseStream.Length) throw new ArgumentOutOfRangeException("length"); var parts = new List<INode>(); var pendingStatic = new List<byte>(); // Add the bytes representing decode tag (if exist) and length to pendingStatic if (lenByte.Count > 0) { String forText = BitConverter.ToString(lenByte.ToArray()).Replace("-", String.Empty) + ">"; parts.Add(new Nodes.StaticString(forText)); } // while loop until reaching the end while (input.BaseStream.Position < end) { var v = input.ReadByte(); if (v == TagStartMarker) { // What this function does: // If no item in "pending", just return // A list of interface can take any instance of that interface // TargetParts adds an element, which is the string version of "pending" // Finally, remove everything in "pending" // P.S. it modifies the references directly. (list = reference type) AddStatic(pendingStatic, parts); // DecodeTag: byte -> string; added into "parts" parts.Add(DecodeTag(input)); if (input.BaseStream.Position > end) throw new InvalidOperationException(); } else pendingStatic.Add(v); } AddStatic(pendingStatic, parts); // Add <hex: if needed if (lenByte.Count > 0) { parts.Add(new Nodes.StaticString("<hex:")); } return new XivString(parts); } ``` 如果讀入的bytes出現代表指令碼開頭的`0x02`,就會呼叫以下函式。這個函式會將輸入的binary code轉換成自定義的class `INode`。 ```csharp=159 private INode DecodeTag(BinaryReader input) { // the first byte means the tag type var tag = (TagType)input.ReadByte(); // edited // the second byte(s) means the length of commnad List<byte> lengthByte = new List<byte> { }; var length = GetInteger(input, lengthByte); String lengthByteStr = BitConverter.ToString(lengthByte.ToArray()).Replace("-", String.Empty); var end = input.BaseStream.Position + length; // System.Diagnostics.Trace.WriteLine(string.Format("{0} @ {1:X}h+{2:X}h", tag, input.BaseStream.Position, length)); TagDecoder decoder = null; // ref and out: // both means to modify the reference; // "out" means it may not be initialized yet, so has to be done in the function. // 這個方法傳回時,如果找到索引鍵,則包含與指定索引鍵相關聯的值,否則為 value 參數類型的預設值。這個參數會以未初始化的狀態傳遞。 _TagDecoders.TryGetValue(tag, out decoder); // "??" operator: return the left side if it is not null; otherwise, return the right side. // If tag in _TagDecoders, decoder will be that decoder; otherwise, it will be DefaultTagDecoder var result = (decoder ?? DefaultTagDecoder)(input, tag, length, lengthByteStr); if (input.BaseStream.Position != end) { // Triggered by two entries in LogMessage as of 3.15. // Looks like a tag has some extra bits, as the end length is a proper TagEndMarker. System.Diagnostics.Debug.WriteLine(string.Format("Position mismatch in XivStringDecoder.DecodeTag. Position {0} != predicted {1}.", input.BaseStream.Position, end)); input.BaseStream.Position = end; } if (input.ReadByte() != TagEndMarker) throw new InvalidDataException(); return result; } ``` 修改各個Tag Decoder的規則: ```csharp=192 #region Generic protected INode DecodeTagDefault(BinaryReader input, TagType tag, int length, String lenByte) { return new Nodes.DefaultElement(tag, input.ReadBytes(length), lenByte); } protected INode DecodeExpression(BinaryReader input) { var t = input.ReadByte(); // expressionTypeByte = t; return DecodeExpression(input, (DecodeExpressionType)t); } protected INode DecodeExpression(BinaryReader input, DecodeExpressionType exprType) { var t = (byte)exprType; if (t < 0xD0) { return new Nodes.StaticInteger(t - 1, ((byte)t).ToString("X2")); } if (t < 0xE0) { return new Nodes.TopLevelParameter(t - 1, ((byte)t).ToString("X2")); } List<byte> addByte = new List<byte> { }; addByte.Add((Byte)exprType); switch (exprType) { case DecodeExpressionType.Decode: { var len = GetInteger(input, addByte); // XIVString is also an INode return Decode(input, len, addByte); } case DecodeExpressionType.Byte: { var expr = GetInteger(input, IntegerType.Byte, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int16_MinusOne: { var expr = GetInteger(input, IntegerType.Int16, addByte) - 1; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int16_1: case DecodeExpressionType.Int16_2: { var expr = GetInteger(input, IntegerType.Int16, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_MinusOne: { var expr = GetInteger(input, IntegerType.Int24, addByte) - 1; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24: { var expr = GetInteger(input, IntegerType.Int24, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_Lsh8: { var expr = GetInteger(input, IntegerType.Int24, addByte) << 8; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_SafeZero: { var v16 = input.ReadByte(); var v8 = input.ReadByte(); var v0 = input.ReadByte(); addByte.Add(v16); addByte.Add(v8); addByte.Add(v0); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); int v = 0; if (v16 != byte.MaxValue) v |= v16 << 16; if (v8 != byte.MaxValue) v |= v8 << 8; if (v0 != byte.MaxValue) v |= v0; return new Nodes.StaticInteger(v, lenByte); } case DecodeExpressionType.Int32: { var expr = GetInteger(input, IntegerType.Int32, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.GreaterThanOrEqualTo: case DecodeExpressionType.GreaterThan: case DecodeExpressionType.LessThanOrEqualTo: case DecodeExpressionType.LessThan: case DecodeExpressionType.NotEqual: case DecodeExpressionType.Equal: { var left = DecodeExpression(input); var right = DecodeExpression(input); return new Nodes.Comparison(exprType, left, right); } case DecodeExpressionType.IntegerParameter: case DecodeExpressionType.PlayerParameter: case DecodeExpressionType.StringParameter: case DecodeExpressionType.ObjectParameter: { var parameter = DecodeExpression(input); return new Nodes.Parameter(exprType, parameter); } default: throw new NotSupportedException(); } } protected INode DecodeGenericElement(BinaryReader input, TagType tag, int length, String lenByte, int argCount, bool hasContent) { if (length == 0) { return new Nodes.EmptyElement(tag, lenByte); } var arguments = new INode[argCount]; for (var i = 0; i < argCount; ++i) { arguments[i] = DecodeExpression(input); } INode content = null; if (hasContent) { content = DecodeExpression(input); } return new Nodes.GenericElement(tag, content, lenByte, arguments); } protected INode DecodeGenericElementWithVariableArguments(BinaryReader input, TagType tag, int length, String lenByte, int minCount, int maxCount) { var end = input.BaseStream.Position + length; var args = new List<INode>(); for (var i = 0; i < maxCount && input.BaseStream.Position < end; ++i) { args.Add(DecodeExpression(input)); } return new Nodes.GenericElement(tag, null, lenByte, args); } protected INode DecodeGenericSurroundingTag(BinaryReader input, TagType tag, int length, String lenByte) { if (length != 1) throw new ArgumentOutOfRangeException("length"); List<byte> insideLenByte = new List<byte> { }; var status = GetInteger(input, insideLenByte); lenByte += BitConverter.ToString(insideLenByte.ToArray()).Replace("-", String.Empty); if (status == 0) return new Nodes.CloseTag(tag, lenByte); if (status == 1) return new Nodes.OpenTag(tag, lenByte, null); /* should be lenByte or insideLenByte? */ throw new InvalidDataException(); } #endregion } ``` ```csharp=+ #region Specific protected INode DecodeZeroPaddedValue(BinaryReader input, TagType tag, int length, String lenByte) { var val = DecodeExpression(input); var arg = DecodeExpression(input); return new GenericElement(tag, val, lenByte, arg); } protected INode DecodeColor(BinaryReader input, TagType tag, int length, String lenByte) { var t = input.ReadByte(); // I think the byte should be added if (length == 1 && t == 0xEC) return new Nodes.CloseTag(tag, lenByte + t.ToString("X2")); var color = DecodeExpression(input, (DecodeExpressionType)t); return new Nodes.OpenTag(tag, lenByte, color); } protected INode DecodeFormat(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var arg1 = DecodeExpression(input); var arg2 = new Nodes.StaticByteArray(input.ReadBytes((int)(end - input.BaseStream.Position))); return new Nodes.GenericElement(tag, null, lenByte, arg1, arg2); } protected INode DecodeIf(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var condition = DecodeExpression(input); INode trueValue, falseValue; DecodeConditionalOutputs(input, (int)end, out trueValue, out falseValue); return new Nodes.IfElement(tag, condition, trueValue, falseValue, lenByte); } protected INode DecodeIfEquals(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var left = DecodeExpression(input); var right = DecodeExpression(input); /* var trueValue = DecodeExpression(input); INode falseValue = null; if (input.BaseStream.Position != end) falseValue = DecodeExpression(input);*/ INode trueValue, falseValue; DecodeConditionalOutputs(input, (int)end, out trueValue, out falseValue); return new Nodes.IfEqualsElement(tag, left, right, trueValue, falseValue, lenByte); } protected void DecodeConditionalOutputs(BinaryReader input, int end, out INode trueValue, out INode falseValue) { var exprs = new List<INode>(); while (input.BaseStream.Position != end) { var expr = DecodeExpression(input); exprs.Add(expr); } // Only one instance with more than two expressions (LogMessage.en[1115][4]) // TODO: Not sure how it should be handled, discarding all but first and second for now. if (exprs.Count > 0) trueValue = exprs[0]; else trueValue = null; if (exprs.Count > 1) falseValue = exprs[1]; else falseValue = null; } protected INode DecodeSwitch(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var caseSwitch = DecodeExpression(input); var cases = new Dictionary<int, INode>(); var i = 1; while (input.BaseStream.Position < end) cases.Add(i++, DecodeExpression(input)); return new Nodes.SwitchElement(tag, caseSwitch, cases, lenByte); } #endregion ``` 下面是處理指令長度的函式。在一般情況下,指令長度只會有一個byte,但若數字較大也會需要有兩個bytes的時候。 用舉例來看看下面的程式碼。當指令只有一個byte`0xC8`,這個byte會被放進lenByte裡面回傳(因為List是reference type,在函式裡加東西進去在函式外也有效)。因為唯一的byte被讀完了,這時候的`GetInteger(input, type, lenByte)`可以想成`GetInteger(空的input, 0xC8, lenByte{0xC8})`。 ```csharp=+ #region Shared protected static int GetInteger(BinaryReader input, List<byte> lenByte) { // added new function var t = input.ReadByte(); var type = (IntegerType)t; lenByte.Add(t); return GetInteger(input, type, lenByte); } protected static int GetInteger(BinaryReader input, IntegerType type, List<byte> lenByte) { const byte ByteLengthCutoff = 0xF0; var t = (byte)type; if (t < ByteLengthCutoff) return t - 1; switch (type) { case IntegerType.Byte: { byte res = input.ReadByte(); lenByte.Add(res); return (res); } case IntegerType.ByteTimes256: { byte res = input.ReadByte(); lenByte.Add(res); return (res * 256); } case IntegerType.Int16: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } case IntegerType.Int24: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 16; res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } case IntegerType.Int32: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 24; res = input.ReadByte(); lenByte.Add(res); v |= res << 16; res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } default: throw new NotSupportedException(); } } #endregion ``` 接著修改以下相應檔案(附例子): * `DefaultElement.cs` * `EmptyElement.cs` * `GenericElement.cs` * `StaticInteger.cs` * `199` → `C8` * `CloseTag.cs`、`OpenTag.cs` * 兩者皆會被`<Emphasis></Emphasis>`和`<Color(數字)></color>`兩種狀況呼叫。 * `<Emphasis><Value>IntegerParameter(1)</Value></Emphasis>` → `<hex:021A020203>` `<Value>IntegerParameter(1)</Value>` `<hex:021A020103>` * `<Color(-15523537)><Unknown14>FEFFB1BACD</Unknown14>選擇大國防聯軍<Unknown14>EC</Unknown14></Color>` → `<hex:021306FEFF13212F03>` `<Unknown14>FEFFB1BACD</Unknown14>選擇大國防聯軍<Unknown14>EC</Unknown14>` `<hex:021302EC03>` * 其餘字串交由其他檔案處理 * `IfElement.cs`、`IfEqualsElement.cs` * `<If(GreaterThan(IntegerParameter(1),9999))>9,999+<Else/><Format(IntegerParameter(1),FF022C)/></If>` * → `<hex:02081A` `GreaterThan(IntegerParameter(1),9999)` `FF07>9,999+<hex:FF0A>` `<Format(IntegerParameter(1),FF022C)/>` `<hex:03>` * 其餘字串交由其他檔案處理 * `SwitchElement.cs` * `<Switch(IntegerParameter(2))><Case(1)>格里達尼亞新街</Case><Case(2)>彎枝牧場</Case><Case(3)>霍桑山寨</Case><Case(4)>石場水車</Case><Case(5)>恬靜路營地</Case><Case(6)>秋瓜浮村</Case></Switch>` → `<hex:020963E803FF16>格里達尼亞新街<hex:FF0D>彎枝牧場<hex:FF0D>霍桑山寨<hex:FF0D>石場水車<hex:FF10>恬靜路營地<hex:FF0D>秋瓜浮村<hex:03>` * `TopLevelParameter.cs` * 觀察程式碼,這個建構子會在`DecodeExpression`裡面,當代表表示式類型的byte在`0xD0`到`0xDF`之間(含)時呼叫。 * `TopLevelParameter(222)` → `DF` * `Comparison.cs` * `Equal(IntegerParameter(1),1)` → `E4` `IntegerParameter(1),1` * `ArgumentCollection.cs` * `(IntegerParameter(1),1)` → `IntegerParameter(1)` `02` * `Parameter.cs` * `IntegerParameter(1)` → `E802` 通常是將各個INode新增代表長度的byte(原本程式會將這些bytes轉成人類可讀的數字,但現在我們要繼續保留HEX的形式),方便我們output時使用。 需要修改的地方包括properties、get functions、`ToString()`。以`DefaultElement.cs`為例。我們增加了作為String傳入的`lenByte`,接著修改`ToString()`函數。 ```csharp=7 namespace SaintCoinach.Text.Nodes { public class DefaultElement : INode { private readonly TagType _Tag; private readonly StaticByteArray _Data; private readonly String _LenByte; public TagType Tag { get { return _Tag; } } public INode Data { get { return _Data; } } public String LenByte { get { return _LenByte; } } NodeFlags INode.Flags { get { return NodeFlags.IsStatic; } } public DefaultElement(TagType tag, byte[] innerBuffer, String lenByte) { _Tag = tag; _Data = new StaticByteArray(innerBuffer); _LenByte = lenByte; } public override string ToString() { var sb = new StringBuilder(); ToString(sb); return sb.ToString(); } public void ToString(StringBuilder builder) { // edit here!!!! builder.Append(StringTokens.TagOpen); builder.Append("hex:02"); builder.Append(((byte)Tag).ToString("X2")); /* X means hex, 2 means 2-digit */ builder.Append(LenByte); if (_Data.Value.Length == 0) { builder.Append("03"); builder.Append(StringTokens.TagClose); } else { _Data.ToString(builder); builder.Append("03"); builder.Append(StringTokens.TagClose); } /* builder.Append("DefaultElement"); builder.Append(StringTokens.TagOpen); builder.Append(Tag); if (_Data.Value.Length == 0) { builder.Append(StringTokens.ElementClose); builder.Append(StringTokens.TagClose); } else { builder.Append(StringTokens.TagClose); _Data.ToString(builder); builder.Append(StringTokens.TagOpen); builder.Append(StringTokens.ElementClose); builder.Append(Tag); builder.Append(StringTokens.TagClose); } */ } public T Accept<T>(SaintCoinach.Text.Nodes.INodeVisitor<T> visitor) { return visitor.Visit(this); } } } ``` ## 4 Launch4j 在Eclipse輸出jar: 1. 檔案 > 匯出 > 可執行的jar檔案 2. 選項:extract required libraries into generated JAR JAR轉exe: 1. 下載[launch4j](http://launch4j.sourceforge.net/) 2. 解壓縮,執行launch4j.exe 3. Basic分頁,選擇output file和Jar 4. Header分頁,選擇GUI 5. JRE分頁,使用Eclipse的JRE 6. ~~我們將原本位於 `C:\Users\User\.p2\pool\plugins\org.eclipse.justj.openjdk.hotspot.jre.full.win32.x86_64_16.0.1.v20210528-1205\jre`的bin和lib資料夾複製進和exe同層的`jre`資料夾~~ 改用4.1所記載的方式產生JRE 8. 在bundled JRE path的地方填 `jre` 9. 最小JRE版本為16.0.1 10. 點擊上方按鈕儲存設定,然後點擊播放鍵產生.exe ### 4.1 縮減JRE大小 Check dependencies: ``` jdeps -s <輸出的jar> ``` Example Output: ``` FFXIVChnTextPatch-0211.jar -> java.base FFXIVChnTextPatch-0211.jar -> java.desktop FFXIVChnTextPatch-0211.jar -> java.logging FFXIVChnTextPatch-0211.jar -> java.naming FFXIVChnTextPatch-0211.jar -> java.security.jgss FFXIVChnTextPatch-0211.jar -> java.sql FFXIVChnTextPatch-0211.jar -> not found ``` Produce JRE: ``` jlink --module-path <JDK module path> --add-modules <listed modules> --output <output path> ``` Example: ``` jlink --module-path "D:\Java\jdk-16.0.1\jmods" --add-modules java.base,java.desktop,java.logging,java.naming,java.security.jgss,java.sql --output ".\jre" ``` ### 4.2 參考資料 - https://stackoverflow.com/questions/7071133/how-to-bundle-a-jre-with-launch4j/7582064 - https://www.796t.com/article.php?id=255747 - https://stackoverflow.com/questions/62291424/jdeps-returns-not-found - https://stackoverflow.com/questions/47222226/how-to-inject-module-declaration-into-jar ## 5 結語 到這邊,基本上就解決了所有手動漢化會遇到的問題。 ## A 附錄 ### A.1 測試自己有沒有成功 除了直接進入遊戲以外,也可以用「迴圈」測試自己有沒有漢化正確。通常會出問題的地方在於SaintCoinach對於指令碼的處理。 0. 備份遊戲檔案 1. 先用已漢化的覆蓋檔覆蓋遊戲檔案 2. 使用修改過的SaintCoinach輸出CSV 3. 將rawexd資料夾下的CSV檔丟到FFXIVChnTextPatch的resource資料夾下 4. FFXIVChnTextPatch以CSV模式執行一遍漢化 5. 再次使用修改過的SaintCoinach輸出CSV 如果以上步驟會出問題,就表示漢化處理上有失敗的地方,即使進入遊戲也無法正常讀取。 ### A.2 Build Bug 這是一開始用GitHub版本的漢化器的bug,後來改用反編譯版本後已不需要,留做參考。 嘗試一、把下面這行註解掉(後來發現下面的解決方法,這個方法僅留供參考) ```java=74 percentPanel.percentShow((double)(++fileCount) / (double)fileList.size()); ``` 嘗試二、在ReplaceEXDF.java新增`this.percentPanel = percentPanel;` ```java=29 public ReplaceEXDF(String pathToIndexSE, String pathToIndexCN, PercentPanel percentPanel) { this.pathToIndexSE = pathToIndexSE; this.pathToIndexCN = pathToIndexCN; this.fileList = new ArrayList<String>(); this.exQuestMap = new HashMap(); this.transMap = new HashMap<>(); this.lang = Config.getProperty("Language"); // added this.percentPanel = percentPanel; } ``` ### A.3 字體問題觀察 * 覆蓋後再使用漢化器漢化,會發現字體包體積變大,進遊戲後顯示正常。推測是因為ReplaceFont是在後面增加字型,沒有影響到前面原漢化包已經加上去的字型。 * 使用過去的使用起來沒問題的原版漢化器測試,發現也有字型亂碼的問題。推測是遊戲更新導致漢化器的ReplaceFont出了問題。 * 實際觀察binary檔案,發現用漢化器添加字型就是檔案後面加上`(4948216 - 4297736 = 650480)`行(一行16 bytes)。 * 而比較未漢化的檔案和覆蓋用的漢化檔,發現2、2383489、2383492行有細微的變動。但除此之外,在3624696行以前都相同。 * 接著,第3625697-3784843(原版)/3832883(覆蓋版)行有明顯的差異。 * 原版的第3784844-4145207行和覆蓋版的3832884-4193247相同。 * 原版的第4244753-4249744行和覆蓋版的4310561-4315552相同。 * 原版的第4288864-4297736行和覆蓋版的4363560-4372432相同。 #### A.3.1 SQPACK研究 用一個簡單的程式證明覆蓋檔擁有漢化器所注入的字型,只是散落四處: ```python= a = {} b = [] with open('Han.txt', 'r') as f: number = 0 for line in f: a[line] = number number += 1 with open('AfterProcess Trim.txt', 'r') as f: for line in f: if line in a: # print(line, a[line]) pass else: print("line not found!", line) ``` 結果沒有print出任何東西。 ### A.4 其他 * 文本中的指令就算有問題,也不一定會在讀取遊戲時就崩潰,有可能在需要顯示該文字時才出問題。 * 6.1更新發現:Completion.csv中,#2200-#2221有指令編號,需隨版本更新。