chore: initialize age-reimpl repo
Reverse-engineering + open reimplementation workspace for Eushully's AGE/SYS4 engine (first target: Himegari). The repo root is age-reimpl/; the original game install and the extracted ALF data are siblings outside the repo and are never tracked. build/ (derived corpora) is gitignored and regenerated by the tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
612
tools/age_opcodes.py
Normal file
612
tools/age_opcodes.py
Normal file
@@ -0,0 +1,612 @@
|
||||
"""AGE (Eushully SYS4/SYS5) opcode + operand-type tables.
|
||||
|
||||
Transcribed verbatim from Kelebek1/Eushully-Decompiler `age-shared.cpp`
|
||||
(vm-map/kelebek1-age-shared.cpp). Validated against all 476 Himegari SYS4
|
||||
scripts: every instruction decodes with zero unknown opcodes when the code
|
||||
stream is walked as `<opcode:u32> + argc*(<argtype:u32><value:u32>)`,
|
||||
instruction length = 1 + 2*argc dwords.
|
||||
|
||||
Handler labels `u004XXXXX` are AGE.EXE addresses from Kelebek's reference
|
||||
title (a later AGE game); the opcode NUMBER + argument_count are the stable
|
||||
ABI and are correct for Himegari. Bare-hex/`dev_ukn` labels = unnamed semantics.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# op_code -> (label, argument_count)
|
||||
OPCODES: dict[int, tuple[str, int]] = {
|
||||
0x0001: ('u004149C0', 0), # error
|
||||
0x0002: ('exit', 0),
|
||||
0x0003: ('call-script', 1), # call another script, param = SYSTEM4.bin index
|
||||
0x0004: ('u00417E30', 2),
|
||||
0x0005: ('ret', 0),
|
||||
0x0006: ('u00417E80', 2),
|
||||
0x0007: ('u00417F90', 1),
|
||||
0x0008: ('u00417FC0', 1),
|
||||
0x0009: ('exit-script', 0),
|
||||
0x000a: ('u00424170', 2),
|
||||
0x000b: ('u00418090', 11),
|
||||
0x000c: ('u004149E0', 0),
|
||||
0x000d: ('u004181A0', 4),
|
||||
0x000e: ('u00418200', 12),
|
||||
0x000f: ('u00418300', 1),
|
||||
0x0010: ('u00414A00', 4),
|
||||
0x0011: ('u00418330', 9),
|
||||
0x0012: ('u004183F0', 1),
|
||||
0x0013: ('u00418420', 4),
|
||||
0x0014: ('u00414A20', 0),
|
||||
0x0015: ('u00418490', 5),
|
||||
0x0016: ('u00418520', 2),
|
||||
0x0017: ('u00418560', 2),
|
||||
0x001e: ('u004185B0', 8),
|
||||
0x001f: ('u00418690', 12),
|
||||
0x0020: ('u004187C0', 6),
|
||||
0x0021: ('u00418860', 2),
|
||||
0x0022: ('u00418920', 2),
|
||||
0x0023: ('u004189D0', 2),
|
||||
0x0024: ('u00418A90', 2),
|
||||
0x0025: ('u00418B40', 3),
|
||||
0x0026: ('u00418C00', 4),
|
||||
0x0027: ('u00418CC0', 4),
|
||||
0x0028: ('u00418D90', 4),
|
||||
0x002a: ('u00418E60', 4),
|
||||
0x002b: ('u00418F30', 5),
|
||||
0x002c: ('u00419010', 5),
|
||||
0x002d: ('u004190A0', 12),
|
||||
0x002e: ('u004194B0', 5),
|
||||
0x002f: ('u004195A0', 4),
|
||||
0x0030: ('u00419670', 5),
|
||||
0x0031: ('u00419750', 4),
|
||||
0x0032: ('u004197C0', 10),
|
||||
0x0033: ('u00419900', 6),
|
||||
0x0034: ('u004199C0', 12),
|
||||
0x0035: ('u00419AF0', 11),
|
||||
0x0036: ('u00419C00', 3),
|
||||
0x0037: ('u00419C90', 11),
|
||||
0x0038: ('u00419DA0', 12),
|
||||
0x0050: ('add', 3), # add. param1 = param2 + param3
|
||||
0x0051: ('sub', 3), # sub. param1 = param2 - param3
|
||||
0x0052: ('mul', 3), # mul. param1 = param2 * param3
|
||||
0x0053: ('div', 3), # div. param1 = param2 / param3
|
||||
0x0054: ('mod', 3), # mod. param1 = param2 % param3
|
||||
0x0055: ('mov', 2), # mov. param1 = param2
|
||||
0x0056: ('and', 3), # and. param1 = param2 & param3
|
||||
0x0057: ('or', 3), # or. param1 = param2 | param3
|
||||
0x0058: ('sar', 3), # sar. param1 = param2 >> param3
|
||||
0x0059: ('shl', 3), # shl. param1 = param2 << param3
|
||||
0x005a: ('eq', 3), # eq. param1 = param2 == param3
|
||||
0x005b: ('ne', 3), # ne. param1 = param2 != param3
|
||||
0x005c: ('lt', 3), # lt. param1 = param2 < param3
|
||||
0x005d: ('lte', 3), # lte. param1 = param2 <= param3
|
||||
0x005e: ('gr', 3), # gr. param1 = param2 > param3
|
||||
0x005f: ('gre', 3), # gre. param1 = param2 >= param3
|
||||
0x0060: ('u0041A270', 2),
|
||||
0x0061: ('lookup-array', 3), # lookup. param1 = param2[param3]
|
||||
0x0062: ('u0041A360', 3),
|
||||
0x0063: ('u00414A60', 2),
|
||||
0x0064: ('copy-local-array', 2),
|
||||
0x0065: ('u00414AA0', 2),
|
||||
0x0066: ('u00414AE0', 3),
|
||||
0x0067: ('u00414B20', 3),
|
||||
0x0068: ('u00414B60', 3),
|
||||
0x0069: ('u00414BA0', 3),
|
||||
0x006a: ('u00414BE0', 3),
|
||||
0x006b: ('u00414C20', 3),
|
||||
0x006c: ('copy-to-global', 2), # loop copy local value to global array, param1 = array start, param2 = count
|
||||
0x006d: ('u00416960', 0),
|
||||
0x006e: ('show-text', 2),
|
||||
0x006f: ('end-text-line', 1),
|
||||
0x0070: ('u0041A750', 5),
|
||||
0x0071: ('u0041A7B0', 1),
|
||||
0x0072: ('wait-for-input', 1),
|
||||
0x0073: ('u0041AB30', 10),
|
||||
0x0074: ('u0041AC00', 1),
|
||||
0x0075: ('u0041AC30', 1),
|
||||
0x0076: ('u0041AC60', 1),
|
||||
0x0077: ('u0041ACB0', 1),
|
||||
0x0078: ('u0041AD00', 1),
|
||||
0x0079: ('u0041AD30', 3),
|
||||
0x007a: ('u0041AD70', 3),
|
||||
0x007b: ('u0041ADB0', 2), # ukn, both args point to code locations
|
||||
0x007c: ('u00416A90', 0),
|
||||
0x007d: ('u0041AE00', 2),
|
||||
0x007e: ('u0041AEA0', 1),
|
||||
0x007f: ('u00414C60', 1),
|
||||
0x0080: ('u0041AF00', 1),
|
||||
0x0081: ('u0041AF30', 1),
|
||||
0x0082: ('u0041AF80', 5),
|
||||
0x0083: ('u00414C90', 3),
|
||||
0x0084: ('u0041AFE0', 1),
|
||||
0x0085: ('u00414CF0', 0),
|
||||
0x0086: ('u0041B210', 1),
|
||||
0x0087: ('u00414D10', 0),
|
||||
0x0088: ('u0041B290', 1),
|
||||
0x0089: ('u0041B2E0', 4),
|
||||
0x008a: ('u0041B330', 6),
|
||||
0x008b: ('u0041B3D0', 1),
|
||||
0x008c: ('jmp', 1),
|
||||
0x008d: ('u0041BCE0', 2),
|
||||
0x008e: ('u0041BD60', 1),
|
||||
0x008f: ('call', 1),
|
||||
0x0090: ('u0041BEB0', 7), # ukn, args 5, 6 and 7 point to code locations
|
||||
0x0091: ('u0041BFB0', 1),
|
||||
0x0092: ('u0041C030', 2),
|
||||
0x0093: ('u00415040', 0),
|
||||
0x0094: ('u00415090', 0),
|
||||
0x0095: ('u0041C0C0', 2),
|
||||
0x0096: ('u004150C0', 0),
|
||||
0x0097: ('u0041C150', 5),
|
||||
0x00a0: ('jcc', 3),
|
||||
0x00a1: ('u00427C00', 0),
|
||||
0x00a2: ('u00427FD0', 2),
|
||||
0x00a3: ('u004244D0', 2),
|
||||
0x00aa: ('u0041C270', 2),
|
||||
0x00ab: ('u0041C330', 2),
|
||||
0x00ac: ('u0041C3E0', 9),
|
||||
0x00ad: ('u00415110', 0),
|
||||
0x00ae: ('u00415130', 0),
|
||||
0x00af: ('u00415480', 0),
|
||||
0x00b0: ('u0041C530', 1),
|
||||
0x00b1: ('u0041C560', 1),
|
||||
0x00b2: ('u0041C590', 2),
|
||||
0x00b3: ('u004154B0', 0),
|
||||
0x00b4: ('play-sound-effect', 2), # play a sound effect/ambient. param1 = file index, param2 = play mode?
|
||||
0x00b5: ('u0041D050', 1),
|
||||
0x00b6: ('u0041D080', 1),
|
||||
0x00b7: ('u0041D0E0', 1),
|
||||
0x00b8: ('u00415520', 0),
|
||||
0x00b9: ('u0041D140', 1),
|
||||
0x00ba: ('u0041D0B0', 1),
|
||||
0x00bb: ('u0041D250', 1),
|
||||
0x00bc: ('u0041D280', 1),
|
||||
0x00bd: ('u00415570', 1),
|
||||
0x00be: ('u004155E0', 1),
|
||||
0x00bf: ('play-bgm', 1), # param1 = bgm number
|
||||
0x00c0: ('u00415620', 1),
|
||||
0x00c1: ('u00415650', 0),
|
||||
0x00c2: ('u0041D2B0', 2),
|
||||
0x00c3: ('u0041D390', 1),
|
||||
0x00c4: ('play-voice', 1),
|
||||
0x00c5: ('u0041D4A0', 2),
|
||||
0x00c6: ('u0041D5D0', 2),
|
||||
0x00c7: ('u0041D760', 2),
|
||||
0x00c8: ('sleep', 1), # param1 = sleep time?
|
||||
0x00c9: ('u00415770', 0),
|
||||
0x00ca: ('u004157A0', 0),
|
||||
0x00cb: ('u00415800', 1),
|
||||
0x00cc: ('mouse_callback', 2), # sets mouse/keyboard callback location, param1 = id, param2 = offset (minus header, not multiplied by 4)
|
||||
0x00cd: ('get-input-type', 0), # get input type, mouse, keyboard, pad etc
|
||||
0x00ce: ('u0041E0B0', 3),
|
||||
0x00cf: ('u00416D40', 0),
|
||||
0x00d0: ('u00415830', 1),
|
||||
0x00d1: ('u00415860', 0),
|
||||
0x00d2: ('u0041E110', 1),
|
||||
0x00d3: ('u00425960', 0),
|
||||
0x00d4: ('u004266F0', 4), # seems to setup some kind of looping function calls. param1 = ukn, param2 = loop count, param3 = function location?, param4 = function location?
|
||||
0x00d5: ('u004262C0', 1),
|
||||
0x00d6: ('u004267D0', 6),
|
||||
0x00d7: ('u0041E1A0', 1),
|
||||
0x00d8: ('u0041E150', 2),
|
||||
0x00d9: ('u00415880', 0),
|
||||
0x00da: ('u004158B0', 6),
|
||||
0x00fa: ('u00415940', 0),
|
||||
0x00fb: ('joy_callback', 2), # sets code callback for joystick inputs. ID 0-4= left thumb up/down/left/right, 4 = X on Xbox controller etc. param1 = id, param2 = offset (minus header, not multiplied by 4)
|
||||
0x00fc: ('u004159F0', 0),
|
||||
0x00fd: ('u0041E2D0', 2),
|
||||
0x00fe: ('u0041E360', 1),
|
||||
0x00ff: ('u00415A10', 0),
|
||||
0x0100: ('u00415A60', 0),
|
||||
0x0101: ('u00415BF0', 0), # joystick input?
|
||||
0x0102: ('u0041E3C0', 3),
|
||||
0x0103: ('u0041E4A0', 1),
|
||||
0x0104: ('u00415C50', 0),
|
||||
0x0105: ('u0041E4D0', 1),
|
||||
0x0106: ('u00415E40', 1),
|
||||
0x0107: ('u0041E500', 2),
|
||||
0x0108: ('u00415E70', 1),
|
||||
0x0109: ('u00415EC0', 2),
|
||||
0x010a: ('u0041E540', 2),
|
||||
0x010b: ('u0041E5A0', 2),
|
||||
0x010c: ('u0041E5E0', 2),
|
||||
0x010d: ('u00415F10', 1),
|
||||
0x010e: ('u0041E650', 2),
|
||||
0x010f: ('u0041E690', 1),
|
||||
0x012c: ('lookup-array-2d', 5), # 2d array lookup. param1 = param2[(param3 * param4) + param5]
|
||||
0x012d: ('u0041E720', 7),
|
||||
0x012e: ('u0041E940', 8),
|
||||
0x012f: ('u0041ECB0', 4),
|
||||
0x0130: ('u00415F40', 1),
|
||||
0x0131: ('u00415F70', 1),
|
||||
0x0132: ('u0041EF00', 1),
|
||||
0x0133: ('u0041EFF0', 2),
|
||||
0x0134: ('u0041F050', 3),
|
||||
0x0135: ('bit-set', 2), # bts, param1 = param1 OR param2
|
||||
0x0136: ('bit-reset', 2), # btr, param1 = param1 NOR param2
|
||||
0x0137: ('u0041F1C0', 1),
|
||||
0x0138: ('u0041F2B0', 2),
|
||||
0x0139: ('u0041F310', 3),
|
||||
0x013a: ('u0041F3A0', 6),
|
||||
0x013b: ('u0041F440', 7),
|
||||
0x013c: ('u0041F7E0', 1),
|
||||
0x013d: ('u0041F840', 3),
|
||||
0x013e: ('u0041F8D0', 2),
|
||||
0x013f: ('check-bit', 3), # param1 = param2 & (1 << param3). Neg, sbb, neg to get the result as a bool
|
||||
0x0140: ('u0041F9C0', 4),
|
||||
0x0141: ('u0041FAA0', 1),
|
||||
0x0142: ('u0041FB10', 1),
|
||||
0x0143: ('u00415FB0', 0),
|
||||
0x0144: ('u004259D0', 2),
|
||||
0x0145: ('u00416040', 1),
|
||||
0x0146: ('u0041FB40', 1),
|
||||
0x0147: ('u0041FB80', 6),
|
||||
0x0148: ('u004160A0', 1),
|
||||
0x0149: ('u0041FCE0', 1),
|
||||
0x014a: ('u0041FD10', 7),
|
||||
0x014b: ('u0041FF50', 1),
|
||||
0x014c: ('set-agerc-export', 2), # binds an agerc.dll export name to the given number
|
||||
0x014d: ('call-agerc-export', 6), # call the param1 agerc exported function
|
||||
0x0190: ('u0041C5E0', 2),
|
||||
0x0191: ('u0041A4A0', 2),
|
||||
0x0192: ('set-string', 2), # u004252D0 : set-string. param1 = param2
|
||||
0x0193: ('concat', 3), # u00425370 : concat. param1 = param2.concat(param3)
|
||||
0x0194: ('u00425480', 3),
|
||||
0x0195: ('u00425580', 3),
|
||||
0x0196: ('display-furigana', 3), # u0041B400 : display-furigana. param1 = text, param2 = furigana
|
||||
0x0197: ('u0041B510', 1),
|
||||
0x0198: ('u0041B540', 3),
|
||||
0x0199: ('u00414D50', 0),
|
||||
0x019a: ('u00414E50', 1),
|
||||
0x019b: ('u00414E80', 0),
|
||||
0x019c: ('u00414EC0', 0),
|
||||
0x019d: ('u0041C680', 2),
|
||||
0x019e: ('u0041C6E0', 2),
|
||||
0x019f: ('u0041C860', 2),
|
||||
0x01a0: ('u0041C9B0', 9),
|
||||
0x01a1: ('u0041CB40', 2),
|
||||
0x01a2: ('u00428010', 1),
|
||||
0x01a3: ('string-lookup-set', 1), # check the value given exists in save/current data and set. param1 = strings[param1]
|
||||
0x01a4: ('u0041B580', 2),
|
||||
0x01a5: ('set-font', 1), # set-font
|
||||
0x01a6: ('halve-strlen', 2), # halve-strlen? param1 = param2.length() / 2 (rounded down)
|
||||
0x01a7: ('comment', 1), # Developer debug comment
|
||||
0x01a8: ('dev_ukn', 0), # Developer debug something, no function in-game
|
||||
0x01a9: ('u00428090', 1),
|
||||
0x01aa: ('u00425920', 1),
|
||||
0x01ab: ('u0041CCA0', 2),
|
||||
0x01ac: ('u0041CD80', 3),
|
||||
0x01ad: ('u004154F0', 0),
|
||||
0x01ae: ('u0041CED0', 3),
|
||||
0x01af: ('u004245C0', 3),
|
||||
0x01b0: ('u0041A510', 3),
|
||||
0x01b1: ('u0041B5C0', 1),
|
||||
0x01b2: ('u00425790', 1), # to string table?
|
||||
0x01b3: ('u004257D0', 0),
|
||||
0x01b4: ('u004237C0', 0),
|
||||
0x01b5: ('u0041B5F0', 1),
|
||||
0x01b6: ('u00414F60', 1),
|
||||
0x01b7: ('u0041B640', 1),
|
||||
0x01b8: ('u0041B670', 2),
|
||||
0x01b9: ('u0041B710', 2),
|
||||
0x01ba: ('u0041D850', 2),
|
||||
0x01bb: ('u0041B7B0', 1),
|
||||
0x01bc: ('u00415670', 0),
|
||||
0x01bd: ('u0041D910', 1),
|
||||
0x01be: ('u0041D9D0', 2),
|
||||
0x01bf: ('u004156C0', 0),
|
||||
0x01c0: ('u0041DB70', 1),
|
||||
0x01c1: ('u0041B820', 3),
|
||||
0x01c2: ('u0041B860', 2),
|
||||
0x01c3: ('u0041B8A0', 2),
|
||||
0x01c4: ('u00415720', 1),
|
||||
0x01c5: ('u00425800', 4),
|
||||
0x01c6: ('u0041DD80', 2),
|
||||
0x01c7: ('u00414F90', 1),
|
||||
0x01c8: ('toString', 2), # u00425680 : toString
|
||||
0x01c9: ('u0041B8E0', 3),
|
||||
0x01ca: ('u0041B9B0', 1),
|
||||
0x01cb: ('u00414FD0', 1),
|
||||
0x01cc: ('u00415010', 1),
|
||||
0x01cd: ('u0041A560', 2),
|
||||
0x01ce: ('u0041B9F0', 1),
|
||||
0x01cf: ('u0041DA10', 1),
|
||||
0x01d0: ('u0041BA80', 3),
|
||||
0x01d1: ('u0041BAE0', 5),
|
||||
0x01d2: ('u0041BB40', 2),
|
||||
0x01d3: ('u0041BB90', 5),
|
||||
0x01d4: ('u0041BC00', 4),
|
||||
0x01d5: ('u00415700', 0),
|
||||
0x01d6: ('u0041DA40', 2),
|
||||
0x01d7: ('u0041DA80', 2),
|
||||
0x01d8: ('u0041DAD0', 3),
|
||||
0x01d9: ('u0041DB20', 2),
|
||||
0x01f4: ('u004160D0', 0),
|
||||
0x01f5: ('u00416120', 0),
|
||||
0x01f6: ('u00416170', 0),
|
||||
0x01f7: ('u00420270', 2),
|
||||
0x01f8: ('create-texture', 4), # create a new drawable rect. param1 = id, param2 = sizeX, param3 = sizeY, param4 = ukn
|
||||
0x01f9: ('set-texture', 3), # set a texture to a given ID. param1 = file index, param2 = id, param3 = ?
|
||||
0x01fa: ('u00420480', 1),
|
||||
0x01fb: ('draw-texture', 8), # draw a texture. param1 = UI element id?, param2 = textureID, param3 = texX, param4 = texY, param5 = width, param6 = height, param7 = drawX, param8 = drawY
|
||||
0x01fc: ('u004205F0', 1),
|
||||
0x01fd: ('u00420620', 4),
|
||||
0x01fe: ('u004206C0', 5),
|
||||
0x01ff: ('u00420770', 4),
|
||||
0x0200: ('u00420800', 1),
|
||||
0x0201: ('u00416190', 1),
|
||||
0x0202: ('u00420880', 5),
|
||||
0x0203: ('u00420950', 4),
|
||||
0x0204: ('draw-string', 4), # u00420A10 : place-string. param1 = id? param2 = x, param3 = y, param4 = string
|
||||
0x0205: ('u00420A60', 6),
|
||||
0x0206: ('u004161C0', 7),
|
||||
0x0207: ('u00420B00', 8),
|
||||
0x0208: ('u00420BF0', 3),
|
||||
0x0209: ('u00420C50', 5),
|
||||
0x020a: ('u00420CE0', 1),
|
||||
0x020b: ('u00420D50', 7),
|
||||
0x020c: ('u00416200', 0),
|
||||
0x020d: ('u00420E10', 1),
|
||||
0x020e: ('u00416250', 0),
|
||||
0x020f: ('u00420E40', 3),
|
||||
0x0210: ('u00420FF0', 1),
|
||||
0x0211: ('u00421060', 1),
|
||||
0x0212: ('u00421090', 2),
|
||||
0x0213: ('u004210D0', 3),
|
||||
0x0214: ('u00421120', 2),
|
||||
0x0215: ('u00421160', 2),
|
||||
0x0216: ('u004211A0', 2),
|
||||
0x0217: ('u004211E0', 4),
|
||||
0x0218: ('u00421270', 4),
|
||||
0x0219: ('u004212E0', 4),
|
||||
0x021a: ('u00421370', 4),
|
||||
0x021b: ('u004213E0', 1),
|
||||
0x021c: ('u00416270', 0),
|
||||
0x021d: ('u00421410', 2),
|
||||
0x021e: ('u00421450', 6),
|
||||
0x021f: ('u00421510', 7),
|
||||
0x0220: ('u004215D0', 6),
|
||||
0x0221: ('u00421670', 4),
|
||||
0x0222: ('u004216C0', 2),
|
||||
0x0223: ('u00421700', 8),
|
||||
0x0224: ('u00416290', 0),
|
||||
0x0225: ('u00421780', 2),
|
||||
0x0226: ('u004217D0', 5),
|
||||
0x0227: ('u00421880', 6),
|
||||
0x0228: ('u00421940', 5),
|
||||
0x0229: ('u004219E0', 5),
|
||||
0x022a: ('u00421A90', 3),
|
||||
0x022b: ('u00421B30', 4),
|
||||
0x022c: ('u00421BD0', 3),
|
||||
0x022d: ('u00421C60', 5),
|
||||
0x022e: ('u00421D10', 6),
|
||||
0x022f: ('u00421DD0', 5),
|
||||
0x0230: ('u00421E70', 1),
|
||||
0x0231: ('u00421EA0', 4),
|
||||
0x0232: ('u00421EF0', 4),
|
||||
0x0233: ('u00421FB0', 5),
|
||||
0x0234: ('u00422060', 5),
|
||||
0x0235: ('u00422100', 5),
|
||||
0x0236: ('u004221A0', 4),
|
||||
0x0237: ('u00422350', 2),
|
||||
0x0238: ('u00422390', 1),
|
||||
0x0239: ('u004223C0', 6),
|
||||
0x023a: ('u00422420', 2),
|
||||
0x023b: ('u00422460', 7),
|
||||
0x023c: ('u004162B0', 0),
|
||||
0x023d: ('u004162F0', 0),
|
||||
0x023e: ('u004228C0', 2),
|
||||
0x023f: ('u00422930', 2),
|
||||
0x0240: ('u004229A0', 4),
|
||||
0x0241: ('u00422B80', 5),
|
||||
0x0242: ('u00422D60', 2),
|
||||
0x0243: ('u00417070', 0),
|
||||
0x0244: ('u00416360', 0),
|
||||
0x0245: ('u00422DA0', 2),
|
||||
0x0246: ('u00422E10', 2),
|
||||
0x0247: ('u00416390', 1),
|
||||
0x0248: ('u00422E80', 1),
|
||||
0x0249: ('u00422EB0', 3),
|
||||
0x024a: ('u004163C0', 3),
|
||||
0x024d: ('u00422E90', 12),
|
||||
0x024e: ('u00422EA0', 1),
|
||||
0x024f: ('u00422ED0', 10),
|
||||
0x0250: ('u00422F60', 10),
|
||||
0x0251: ('u00422FF0', 12),
|
||||
0x0252: ('u00423000', 1),
|
||||
0x0253: ('u00423019', 2),
|
||||
0x0254: ('u00423049', 5),
|
||||
0x0256: ('u00423050', 5),
|
||||
0x0257: ('257', 5), # Sankai no Yubiwa
|
||||
0x0258: ('u00422FE0', 2),
|
||||
0x0259: ('u00416410', 0),
|
||||
0x025a: ('u00423120', 1),
|
||||
0x025b: ('25B', 1), # Kami no Rhapsody
|
||||
0x025c: ('u00423122', 8),
|
||||
0x025d: ('u00423123', 3),
|
||||
0x025e: ('u00423124', 5),
|
||||
0x025f: ('u00423125', 4),
|
||||
0x0260: ('u00423126', 4),
|
||||
0x0261: ('u00423127', 1),
|
||||
0x0262: ('262', 1), # Amayui 2
|
||||
0x0263: ('263', 1), # Amayui 2
|
||||
0x0264: ('264', 5), # Hyakusen
|
||||
0x02bc: ('u00423020', 11),
|
||||
0x02bd: ('u00423100', 1),
|
||||
0x02be: ('u00423140', 1),
|
||||
0x02bf: ('u00423180', 3),
|
||||
0x02c0: ('u004231C0', 3),
|
||||
0x02c1: ('u00425BC0', 1),
|
||||
0x02c2: ('u00425CD0', 6),
|
||||
0x02c3: ('u00423200', 2),
|
||||
0x02c4: ('u00416450', 0), # using as a way to test logging, originally u00416450
|
||||
0x02c5: ('strlen', 2), # u0042B5D0 : strlen. param1 = param2.length()
|
||||
0x02c6: ('u0042B5E0', 2),
|
||||
0x02c7: ('u0042B5F0', 4),
|
||||
0x02c8: ('u0042B610', 4),
|
||||
0x02c9: ('2C9', 3), # Sankai no Yubiwa
|
||||
0x02cc: ('2CC', 1), # Sankai no Yubiwa
|
||||
0x02cd: ('2CD', 1), # Sankai no Yubiwa
|
||||
0x02ce: ('u0042B616', 1),
|
||||
0x02cf: ('u0042B617', 1),
|
||||
0x02d0: ('u0042B940', 3),
|
||||
0x02d1: ('u0042B950', 3),
|
||||
0x02d2: ('u0042B960', 3),
|
||||
0x02d3: ('u0042B970', 3),
|
||||
0x02d5: ('u0042B990', 2),
|
||||
0x02d7: ('u0042B9B0', 2),
|
||||
0x02d8: ('set-array-to', 3), # Set a given array to the given value x times. loop: param1[param3] = param2; param3++
|
||||
0x02d9: ('u0042BA30', 2),
|
||||
0x02da: ('u004234E0', 8),
|
||||
0x02db: ('u004235C0', 1),
|
||||
0x02dc: ('u0042BA80', 1),
|
||||
0x02dd: ('u0042D880', 2),
|
||||
0x02de: ('u0042BAC0', 2),
|
||||
0x02df: ('u0042BAC1', 3),
|
||||
0x02e0: ('u0042CE0F', 3),
|
||||
0x02e1: ('u0042CE10', 3),
|
||||
0x02e2: ('u0042CE11', 3),
|
||||
0x02e3: ('u0042CE30', 3),
|
||||
0x02e4: ('u0042CE31', 3),
|
||||
0x02e5: ('u0042CE50', 1),
|
||||
0x02e6: ('u0042CE60', 2),
|
||||
0x02e7: ('u0042CE70', 2),
|
||||
0x02e8: ('u0042CE80', 1),
|
||||
0x02e9: ('u0042CE90', 1),
|
||||
0x02ea: ('u0042CEA0', 1),
|
||||
0x02eb: ('u0042CEB0', 1),
|
||||
0x02ec: ('u0042CEC0', 2),
|
||||
0x02ee: ('u0042CEC2', 1),
|
||||
0x02ef: ('u0042CEC3', 11),
|
||||
0x02f0: ('u0042CEC4', 9),
|
||||
0x02f1: ('u0042CEC5', 7),
|
||||
0x02f2: ('u0042CEC6', 6),
|
||||
0x02f3: ('2F3', 6), # La Dea
|
||||
0x02f4: ('2F4', 3), # La Dea
|
||||
0x02f5: ('2F5', 4), # La Dea
|
||||
0x02f6: ('2F6', 1), # La Dea
|
||||
0x02f7: ('2F7', 1), # La Dea
|
||||
0x02f8: ('2F8', 2), # La Dea
|
||||
0x02f9: ('2F9', 7), # La Dea
|
||||
0x02fa: ('2FA', 1), # La Dea
|
||||
0x02fb: ('2FB', 1), # La Dea
|
||||
0x02fc: ('2FC', 5), # Kami no Rhapsody
|
||||
0x02fd: ('2FD', 6), # Kami no Rhapsody
|
||||
0x02fe: ('2FE', 1), # Sankai no Yubiwa
|
||||
0x02ff: ('2FF', 2), # Sankai no Yubiwa
|
||||
0x0300: ('300', 3), # Sankai no Yubiwa
|
||||
0x0301: ('301', 1), # Sankai no Yubiwa
|
||||
0x0302: ('302', 2), # Sankai no Yubiwa
|
||||
0x0303: ('303', 3), # Sankai no Yubiwa
|
||||
0x0304: ('304', 0), # Sankai no Yubiwa
|
||||
0x0305: ('305', 0), # Sankai no Yubiwa
|
||||
0x0306: ('306', 1), # Sankai no Yubiwa
|
||||
0x0307: ('307', 1), # Sankai no Yubiwa
|
||||
0x0308: ('308', 1), # Amayui Alchemy Meister
|
||||
0x030a: ('30A', 2), # Amayui Alchemy Meister
|
||||
0x030c: ('30C', 1), # Tenmei no Conquista
|
||||
0x0320: ('u0043AA20', 10),
|
||||
0x0321: ('u0043AA30', 3),
|
||||
0x0322: ('u0043AA40', 4),
|
||||
0x0323: ('u0043AA50', 5),
|
||||
0x0324: ('u0043AA60', 0),
|
||||
0x0325: ('u0043AA70', 2),
|
||||
0x0326: ('u0043AA80', 4),
|
||||
0x0327: ('u0043AA90', 1),
|
||||
0x0328: ('u0043AAA0', 3),
|
||||
0x0329: ('u0043AAB0', 2),
|
||||
0x032a: ('32A', 1), # Kami no Rhapsody
|
||||
0x032b: ('u0043AAD0', 0),
|
||||
0x032c: ('u0043AAE0', 6),
|
||||
0x032d: ('u0043AAF0', 2),
|
||||
0x032e: ('u0043AB10', 11),
|
||||
0x032f: ('u0043AB11', 1),
|
||||
0x0330: ('u0043AB12', 2),
|
||||
0x0332: ('u0043AB14', 4),
|
||||
0x0334: ('u0043AB16', 1),
|
||||
0x0335: ('u0043AB17', 4),
|
||||
0x0337: ('u0043AB19', 4),
|
||||
0x033b: ('u0043AB1D', 4),
|
||||
0x033d: ('u0043AB1E', 3),
|
||||
0x033e: ('u0043AB1F', 5),
|
||||
0x033f: ('u0043AB20', 3),
|
||||
0x0340: ('340', 1), # Sankai no Yubiwa
|
||||
0x0341: ('341', 2), # Amayui Alchemy Meister
|
||||
0x0342: ('342', 1), # Amayui Alchemy Meister
|
||||
0x0344: ('344', 2), # Amayui Alchemy Meister
|
||||
0x0345: ('345', 3), # Amayui Alchemy Meister
|
||||
0x0349: ('349', 4), # Amayui Alchemy Meister
|
||||
0x034d: ('34D', 6), # Amayui Alchemy Meister
|
||||
0x034e: ('34E', 4), # Amayui Alchemy Meister
|
||||
0x0352: ('352', 3), # Amayui Alchemy Meister
|
||||
0x0353: ('353', 2), # Fuukan no Gransesta
|
||||
0x0354: ('354', 2), # Fuukan no Gransesta
|
||||
0x0358: ('358', 5), # Amayui 2
|
||||
0x035a: ('35A', 5), # Amayui 2
|
||||
0x035b: ('35B', 2), # Fuukan no Gransesta
|
||||
0x035c: ('35C', 2), # Fuukan no Gransesta
|
||||
0x035d: ('35D', 3), # Fuukan no Gransesta
|
||||
0x035f: ('35F', 3), # Fuukan no Gransesta
|
||||
0x0360: ('360', 3), # Fuukan no Gransesta
|
||||
0x0361: ('361', 2), # Fuukan no Gransesta
|
||||
0x0363: ('363', 3), # Amayui 2
|
||||
0x0364: ('364', 3), # Amayui 2
|
||||
0x0384: ('384', 3), # Tenmei no Conquista
|
||||
0x0386: ('386', 11), # Tenmei no Conquista
|
||||
0x0387: ('387', 8), # Tenmei no Conquista
|
||||
0x0388: ('388', 3), # Tenmei no Conquista
|
||||
0x0389: ('389', 6), # Tenmei no Conquista
|
||||
0x038f: ('38F', 6), # Tenmei no Conquista
|
||||
0x0390: ('390', 7), # Tenmei no Conquista
|
||||
0x0391: ('391', 2), # Amayui 2
|
||||
0x0392: ('392', 1), # Tenmei no Conquista
|
||||
0x0393: ('393', 6), # Amayui 2
|
||||
0x0396: ('396', 5), # Tenmei no Conquista
|
||||
0x0398: ('398', 3), # Amayui 2
|
||||
0x0399: ('399', 7), # Tenmei no Conquista
|
||||
0x039b: ('39B', 5), # Amayui 2
|
||||
}
|
||||
|
||||
# argument type tag -> human label (from disassembler.cpp get_type_label)
|
||||
ARG_TYPES: dict[int, str] = {
|
||||
0x0000: 'imm',
|
||||
0x0001: 'float',
|
||||
0x0002: 'string',
|
||||
0x0003: 'global-int',
|
||||
0x0004: 'global-float',
|
||||
0x0005: 'global-string',
|
||||
0x0006: 'global-ptr',
|
||||
0x0008: 'global-string-ptr',
|
||||
0x0009: 'local-int',
|
||||
0x000a: 'local-float',
|
||||
0x000b: 'local-string',
|
||||
0x000c: 'local-ptr',
|
||||
0x000d: 'local-float-ptr',
|
||||
0x000e: 'local-string-ptr',
|
||||
0x8003: 'type-0x8003',
|
||||
0x8005: 'type-0x8005',
|
||||
0x8009: 'type-0x8009',
|
||||
0x800b: 'type-0x800B',
|
||||
}
|
||||
|
||||
# opcodes whose (some) operands are code offsets (label targets), from age-shared.h
|
||||
CONTROL_FLOW = frozenset({0x8C, 0x8F, 0xA0, 0xCC, 0xFB, 0xD4, 0x90, 0x7B})
|
||||
|
||||
# opcode 0x64 arg #1 references an array stored in the footer (not inline)
|
||||
ARRAY_OPCODE = 0x64
|
||||
|
||||
|
||||
def is_label_argument(op: int, arg_index: int, raw_value: int) -> bool:
|
||||
"""Mirror of age-shared.h is_label_argument: is this operand a code-offset label?"""
|
||||
if raw_value == 0xFFFFFFFF:
|
||||
return False
|
||||
if op in (0x8C, 0x8F):
|
||||
return True
|
||||
if op == 0xA0:
|
||||
return arg_index > 0
|
||||
if op in (0xCC, 0xFB):
|
||||
return arg_index > 0
|
||||
if op == 0xD4:
|
||||
return arg_index >= 2
|
||||
if op == 0x90:
|
||||
return arg_index >= 4
|
||||
if op == 0x7B:
|
||||
return True
|
||||
return False
|
||||
98
tools/age_opcodes_himegari.py
Normal file
98
tools/age_opcodes_himegari.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Himegari-specific opcode INFERENCE layer (sits on top of age_opcodes.py).
|
||||
|
||||
`age_opcodes.py` is the verbatim Kelebek table (opcode number + argc, validated 481/481).
|
||||
Many opcodes have only engine-address labels (`u004xxxx`). This module records what the
|
||||
top-frequency unnamed opcodes *mean*, inferred from operand types, disassembly context,
|
||||
and neighbouring named ops (see `vm-map/himegari-opcode-notes.md` for the evidence).
|
||||
|
||||
These are INFERENCES, not ground truth. Fields:
|
||||
name short mnemonic for the disassembler
|
||||
category marker | structural | computational | draw | audio | adv | control
|
||||
noop True = safe for the Godot VM v1 to skip (no state/visible effect expected)
|
||||
False = has an effect; must be implemented (or knowingly stubbed)
|
||||
confidence high | med | low
|
||||
method how the inference was reached / how to confirm:
|
||||
structure | context | harness (confirm via dialogue diff in Phase 4) |
|
||||
frida (needs live capture) | unicorn (micro-exec)
|
||||
note one-line rationale / caveat
|
||||
|
||||
Consumed by `sys4load.render_listing` (nicer disassembly) and, later, the Godot VM.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
INFERRED: dict[int, dict] = {
|
||||
# ---- statement / scope scaffolding: zero-arg, no operands, bracket statements ----
|
||||
0x1f4: dict(name="stmt-begin", category="marker", noop=True, confidence="high",
|
||||
method="structure", note="zero-arg; opens scripts, pairs with stmt-end 0x1f5"),
|
||||
0x1f5: dict(name="stmt-end", category="marker", noop=True, confidence="high",
|
||||
method="structure", note="zero-arg; precedes exit/next-stmt, pairs with 0x1f4"),
|
||||
0x1d5: dict(name="cond-block", category="marker", noop=True, confidence="high",
|
||||
method="context", note="zero-arg; ALWAYS follows jcc — marks conditional body entry"),
|
||||
0x1bc: dict(name="block-mark", category="marker", noop=True, confidence="high",
|
||||
method="context", note="zero-arg; follows jcc/mov, precedes mov/ret — block boundary"),
|
||||
0x1bf: dict(name="call-end", category="marker", noop=True, confidence="med",
|
||||
method="context", note="zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker"),
|
||||
|
||||
# ---- statement metadata carrying an id (tentative no-op, confirm via harness) ----
|
||||
0x21b: dict(name="line-id?", category="marker", noop=True, confidence="med",
|
||||
method="harness", note="1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control"),
|
||||
0x1d2: dict(name="stmt-desc?", category="marker", noop=True, confidence="med",
|
||||
method="harness", note="2 imm; immediately after stmt-begin 0x1f4 — statement descriptor?"),
|
||||
0x258: dict(name="decl?", category="marker", noop=True, confidence="low",
|
||||
method="harness", note="2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?"),
|
||||
|
||||
# ---- structural ----
|
||||
0x71: dict(name="label-def", category="structural", noop=True, confidence="high",
|
||||
method="structure", note="1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id"),
|
||||
|
||||
# ---- input / UI hotspot (u0041Bxxx/Cxxx widget module; see himegari-opcode-notes.md) ----
|
||||
0x90: dict(name="hotspot-branch", category="input", noop=False, confidence="high",
|
||||
method="structure", note="argc7: x y w h + 3 code targets (0xffffffff=unused). "
|
||||
"Cursor/input hotspot hit-test; branches per interaction (inferred hover-enter->flag=1 / "
|
||||
"hover-leave->flag=0 / click->run-action), FALLS THROUGH to pc+1 on no-match "
|
||||
"(design-confirmed: enc.len 15 lands on the next stmt). HEADLESS: fall through = correct "
|
||||
"no-input behaviour (proven safe by 279 CLEAN scenes). Occurs ONLY in a shared ADV-chrome "
|
||||
"subroutine, identical in all 301 ADV scripts (8/script = 5 imm-rect buttons @y=572 "
|
||||
"x=684..772 20x20 toggling G[0x6c9..0x6cd] + 3 local-operand keyed forms). Model live in A2; "
|
||||
"confirm target->state mapping via input capture/Frida."),
|
||||
0x97: dict(name="hotspot-reg?", category="input", noop=False, confidence="med",
|
||||
method="frida", note="argc5: v1 v2 1 1 <action-id imm>; NO code targets. Interleaves with "
|
||||
"0x90 in the ADV-chrome subroutine -> companion register-hotspot / set-widget-action "
|
||||
"(trailing imm = action id 0x0/0x7/0x8). u0041C150, same widget cluster as 0x90/0x91/0x92/0x95."),
|
||||
|
||||
# ---- computational (exact behaviour via Unicorn micro-exec) ----
|
||||
0x215: dict(name="count?", category="computational", noop=False, confidence="med",
|
||||
method="unicorn", note="2 args -> writes global then result tested >0 (gre/lt) — count/search-returns-index helper"),
|
||||
0x1a2: dict(name="resolve-handle?", category="computational", noop=False, confidence="low",
|
||||
method="frida", note="1 local-ptr from lookup-array, then create-texture — resolves a looked-up resource/handle"),
|
||||
|
||||
# ---- ADV / text-display (effectful; confirm via Frida) ----
|
||||
0x7a: dict(name="text-param?", category="adv", noop=False, confidence="med",
|
||||
method="frida", note="3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param"),
|
||||
|
||||
# ---- draw / UI (0x420-0x421 graphics family; effectful; Frida) ----
|
||||
0x202: dict(name="draw-blit?", category="draw", noop=False, confidence="med",
|
||||
method="frida", note="5 args (coords/sizes); preceded by coord arithmetic, near draw ops"),
|
||||
0x203: dict(name="draw?", category="draw", noop=False, confidence="med",
|
||||
method="frida", note="4 args; chains with 0x202/draw-texture"),
|
||||
0x1f7: dict(name="ui-elem?", category="draw", noop=False, confidence="med",
|
||||
method="frida", note="2 args; 0x420 family, pairs with 0x1fa — create/begin a UI element"),
|
||||
0x1fa: dict(name="ui-clear?", category="draw", noop=False, confidence="med",
|
||||
method="frida", note="1 arg (element id); follows 0x1f7 — show/hide/clear UI element by id"),
|
||||
0x217: dict(name="gfx-geom?", category="draw", noop=False, confidence="low",
|
||||
method="frida", note="4 global-ints; part of a 0x217/0x218/0x21a geometry chain"),
|
||||
0x218: dict(name="gfx-geom?", category="draw", noop=False, confidence="low",
|
||||
method="frida", note="4 global-ints; chains with 0x21a/0x217"),
|
||||
0x21a: dict(name="gfx-geom?", category="draw", noop=False, confidence="low",
|
||||
method="frida", note="4 global-ints; chains with 0x218/0x217"),
|
||||
0x1ff: dict(name="draw?", category="draw", noop=False, confidence="low",
|
||||
method="frida", note="4 args (global+imms); follows 0x217, then call"),
|
||||
|
||||
# ---- audio (0x41D family, near play-sound-effect 0xb4) ----
|
||||
0xb6: dict(name="snd-ctrl?", category="audio", noop=False, confidence="low",
|
||||
method="frida", note="1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control"),
|
||||
}
|
||||
|
||||
# Names ending in "?" are low/medium-confidence guesses; the disassembler shows them
|
||||
# so listings read better than `u004xxxx`, but the VM must not treat non-noop ops as
|
||||
# no-ops without Frida/Unicorn/harness confirmation (see `method`).
|
||||
186
tools/extract_init.py
Normal file
186
tools/extract_init.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a *INIT data table to JSON. Auto-detects the table's shape.
|
||||
|
||||
*INIT scripts populate parallel global arrays with static game data. Three shapes seen:
|
||||
|
||||
name — records keyed by a name string. Each record: set-string(name), mov(fields..),
|
||||
set-string(desc). Arrays indexed by record id in lockstep (+1/record).
|
||||
(SKINIT skills, ITINIT items, EBINIT units)
|
||||
numeric— column table with NO names: mov/copy-to-global into parallel int arrays, keyed
|
||||
by an incrementing index column. (CGINIT gallery)
|
||||
footer — copy-local-array (op 0x64) bulk-loads length-prefixed arrays from the file
|
||||
footer into per-record global arrays. The data lives in the footer. (MPINIT maps)
|
||||
|
||||
Records are {id, name?, desc?, fields:{"0x<col_base>": value}} or, for footer tables,
|
||||
{id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals;
|
||||
naming them (attack, cost, …) needs the engine global-var map — later work.
|
||||
|
||||
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
SET_STRING = 0x192
|
||||
MOV = 0x55
|
||||
COPY_TO_GLOBAL = 0x6C
|
||||
COPY_LOCAL_ARRAY = 0x64
|
||||
T_GLOBAL_INT = 3
|
||||
T_GLOBAL_STRING = 5
|
||||
T_IMM = 0
|
||||
|
||||
|
||||
def resolve(name: str) -> Path:
|
||||
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
|
||||
if cand.exists():
|
||||
return cand
|
||||
raise SystemExit(f"not found: {name}.BIN")
|
||||
|
||||
|
||||
def _val(arg):
|
||||
"""Render an operand as an int (immediate) or a {type,value} ref."""
|
||||
t, v = arg
|
||||
return v if t == T_IMM else {"type": f"0x{t:x}", "value": f"0x{v:x}"}
|
||||
|
||||
|
||||
def read_footer_array(scr, off):
|
||||
"""Read a length-prefixed Data_Array at dword `off`: [length][v0..v_{length-1}]."""
|
||||
dw = scr.dwords
|
||||
if not (0 <= off < scr.nbody):
|
||||
return None
|
||||
length = dw[off]
|
||||
if length > scr.nbody or off + 1 + length > scr.nbody:
|
||||
return None
|
||||
return list(dw[off + 1: off + 1 + length])
|
||||
|
||||
|
||||
def detect_mode(scr):
|
||||
ops = [ins.opcode for ins in scr.instructions]
|
||||
has_str = any(ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING
|
||||
for ins in scr.instructions)
|
||||
if has_str:
|
||||
return "name"
|
||||
n_footer = ops.count(COPY_LOCAL_ARRAY)
|
||||
n_int = ops.count(MOV) + ops.count(COPY_TO_GLOBAL)
|
||||
return "footer" if n_footer >= max(4, n_int) else "numeric"
|
||||
|
||||
|
||||
def extract_name(scr):
|
||||
name_base = None
|
||||
for ins in scr.instructions:
|
||||
if ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING:
|
||||
name_base = ins.args[0][1]; break
|
||||
records, cur, prev_gstr, desc_slot, desc_bases = [], None, None, 0, {}
|
||||
for ins in scr.instructions:
|
||||
if ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING:
|
||||
addr = ins.args[0][1]
|
||||
txt = scr.strings.get(ins.args[1][1], (None,))[0] if len(ins.args) > 1 else None
|
||||
if prev_gstr is None or addr < prev_gstr:
|
||||
cur = {"id": addr - name_base, "name": txt, "fields": {}}
|
||||
records.append(cur); desc_slot = 0
|
||||
elif cur is not None:
|
||||
key = "desc" if desc_slot == 0 else f"desc{desc_slot}"
|
||||
cur[key] = txt; desc_bases.setdefault(key, addr - cur["id"]); desc_slot += 1
|
||||
prev_gstr = addr
|
||||
elif ins.opcode == MOV and cur is not None and ins.args and ins.args[0][0] == T_GLOBAL_INT:
|
||||
cur["fields"][f"0x{ins.args[0][1] - cur['id']:x}"] = _val(ins.args[1])
|
||||
return records, {"name_array_base": f"0x{name_base:x}",
|
||||
"desc_array_bases": {k: f"0x{v:x}" for k, v in sorted(desc_bases.items())}}
|
||||
|
||||
|
||||
def _int_writes(scr):
|
||||
"""Ordered (addr, value_arg) for global-int mov / copy-to-global."""
|
||||
out = []
|
||||
for ins in scr.instructions:
|
||||
if ins.opcode in (MOV, COPY_TO_GLOBAL) and ins.args and ins.args[0][0] == T_GLOBAL_INT:
|
||||
out.append((ins.args[0][1], ins.args[1]))
|
||||
return out
|
||||
|
||||
|
||||
def _longest_stride1_column(addrs):
|
||||
"""Pick the primary index array: the stride-1 arithmetic run covering the most records."""
|
||||
seen = set(addrs)
|
||||
best_base, best_len = None, 0
|
||||
for a in sorted(seen):
|
||||
if a - 1 in seen:
|
||||
continue # only start at a run's base
|
||||
n = 0
|
||||
while a + n in seen:
|
||||
n += 1
|
||||
if n > best_len:
|
||||
best_base, best_len = a, n
|
||||
return best_base, best_len
|
||||
|
||||
|
||||
def extract_numeric(scr):
|
||||
writes = _int_writes(scr)
|
||||
base, n = _longest_stride1_column([a for a, _ in writes])
|
||||
if base is None:
|
||||
return [], {}
|
||||
primary = set(range(base, base + n))
|
||||
records, buf = [], []
|
||||
for addr, varg in writes:
|
||||
buf.append((addr, varg))
|
||||
if addr in primary: # primary write closes the record
|
||||
rid = addr - base
|
||||
fields = {f"0x{a - rid:x}": _val(v) for a, v in buf}
|
||||
records.append({"id": rid, "fields": fields})
|
||||
buf = []
|
||||
return records, {"primary_index_base": f"0x{base:x}", "record_span": n}
|
||||
|
||||
|
||||
def extract_footer(scr):
|
||||
records = []
|
||||
for i, ins in enumerate(scr.instructions):
|
||||
if ins.opcode == COPY_LOCAL_ARRAY and ins.args and ins.args[0][0] == T_GLOBAL_INT:
|
||||
addr = ins.args[0][1]
|
||||
foff = ins.args[1][1]
|
||||
vals = read_footer_array(scr, foff)
|
||||
records.append({"id": i, "global_addr": f"0x{addr:x}",
|
||||
"footer_off": f"0x{foff:x}",
|
||||
"length": len(vals) if vals else 0,
|
||||
"values": vals if vals else []})
|
||||
return records, {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
mode_arg = next((sys.argv[i + 1] for i, a in enumerate(sys.argv) if a == "--mode"), None)
|
||||
if not argv:
|
||||
raise SystemExit(__doc__)
|
||||
name = argv[0].upper().removesuffix(".BIN")
|
||||
outname = argv[1] if len(argv) > 1 else name
|
||||
scr = sys4load.load(resolve(name))
|
||||
|
||||
mode = mode_arg or detect_mode(scr)
|
||||
extractor = {"name": extract_name, "numeric": extract_numeric, "footer": extract_footer}[mode]
|
||||
recs, meta = extractor(scr)
|
||||
|
||||
cols = sorted({c for r in recs for c in r.get("fields", {})}, key=lambda h: int(h, 16))
|
||||
out = {"table": name, "source": scr.path.name, "magic": scr.magic, "mode": mode,
|
||||
"record_count": len(recs), **meta,
|
||||
"field_columns": cols if mode != "footer" else None, "records": recs}
|
||||
outpath = paths.BUILD / "data" / f"{outname}.json"
|
||||
outpath.parent.mkdir(parents=True, exist_ok=True)
|
||||
outpath.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"{name}: mode={mode}, {len(recs)} records"
|
||||
+ (f", {len(cols)} field-columns" if mode != 'footer' else "")
|
||||
+ f" -> build/data/{outname}.json")
|
||||
for r in recs[:4]:
|
||||
if mode == "footer":
|
||||
print(f" id {r['id']:>4} {r['global_addr']} <- footer {r['footer_off']} "
|
||||
f"len {r['length']} head={r['values'][:8]}")
|
||||
else:
|
||||
f4 = {k: r['fields'][k] for k in list(r['fields'])[:4]}
|
||||
print(f" id {r['id']:>4} {r.get('name','')!r:12} desc={r.get('desc','')!r} {f4}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
112
tools/extract_phase2.py
Normal file
112
tools/extract_phase2.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 2 batch extraction: disassembly + text corpora from every SYS4 script.
|
||||
|
||||
Outputs (all under build/, workspace-root relative):
|
||||
build/disasm/<NAME>.asm full disassembly listing (one per script)
|
||||
build/text/<NAME>.strings.txt all inline strings in that script
|
||||
build/text/dialogue.jsonl show-text (0x6E) lines only — the translation corpus
|
||||
build/text/strings.jsonl every inline string, tagged by the opcode that references it
|
||||
build/manifest.json per-script stats (instructions, strings, dialogue, decode-clean)
|
||||
|
||||
Authoritative copies: a game-dir override shadows its extracted/DATA1 copy. Run:
|
||||
py -3.11 -X utf8 tools/extract_phase2.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
DATA1 = paths.DATA1
|
||||
BUILD = paths.BUILD
|
||||
DISASM = BUILD / "disasm"
|
||||
TEXT = BUILD / "text"
|
||||
|
||||
SHOW_TEXT = 0x6E # dialogue opcode; type-2 arg = displayed line
|
||||
|
||||
|
||||
def authoritative_scripts() -> dict[str, Path]:
|
||||
"""name -> path, game-dir overrides winning over extracted/DATA1."""
|
||||
return paths.scripts()
|
||||
|
||||
|
||||
def opcode_for_string_ref(scr, value_dword_index: int) -> tuple[int | None, str | None]:
|
||||
"""Given the body index of a string's *value* operand, find the owning instruction."""
|
||||
for ins in scr.instructions:
|
||||
base = ins.offset + 1
|
||||
for a in range(len(ins.args)):
|
||||
if base + 2 * a + 1 == value_dword_index:
|
||||
return ins.opcode, ins.label
|
||||
return None, None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for d in (DISASM, TEXT, BUILD / "data", BUILD / "scripts-json"):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = authoritative_scripts()
|
||||
manifest = []
|
||||
n_dialogue = n_strings = 0
|
||||
skipped = []
|
||||
|
||||
with (TEXT / "dialogue.jsonl").open("w", encoding="utf-8") as dlg, \
|
||||
(TEXT / "strings.jsonl").open("w", encoding="utf-8") as allstr:
|
||||
for name, p in sorted(files.items()):
|
||||
try:
|
||||
scr = sys4load.load(p)
|
||||
except sys4load.Sys4Error as e:
|
||||
skipped.append((name, str(e)))
|
||||
continue
|
||||
|
||||
# 1) disassembly listing
|
||||
(DISASM / f"{Path(name).stem}.asm").write_text(
|
||||
sys4load.render_listing(scr), encoding="utf-8")
|
||||
|
||||
# 2) per-script strings file
|
||||
if scr.strings:
|
||||
lines = [f"0x{off:05x}\t{txt}" for off, (txt, _) in sorted(scr.strings.items())]
|
||||
(TEXT / f"{Path(name).stem}.strings.txt").write_text(
|
||||
"\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
# 3) combined corpora, tagged by referencing opcode
|
||||
for val_idx, off in sorted(scr.string_refs.items(), key=lambda kv: kv[1]):
|
||||
txt = scr.strings.get(off, (None,))[0]
|
||||
if txt is None:
|
||||
continue
|
||||
op, label = opcode_for_string_ref(scr, val_idx)
|
||||
rec = {"file": name, "off": f"0x{off:x}",
|
||||
"op": f"0x{op:x}" if op is not None else None,
|
||||
"label": label, "text": txt}
|
||||
allstr.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
n_strings += 1
|
||||
if op == SHOW_TEXT:
|
||||
dlg.write(json.dumps({"file": name, "off": f"0x{off:x}", "text": txt},
|
||||
ensure_ascii=False) + "\n")
|
||||
n_dialogue += 1
|
||||
|
||||
n, unk, trunc, clean = sys4load.decode_stats(scr)
|
||||
manifest.append({"file": name, "instructions": n, "strings": len(scr.strings),
|
||||
"clean": clean, "unknown": unk, "truncated": trunc})
|
||||
|
||||
(BUILD / "manifest.json").write_text(
|
||||
json.dumps({"scripts": len(manifest), "skipped": skipped,
|
||||
"total_dialogue_lines": n_dialogue, "total_strings": n_strings,
|
||||
"per_script": manifest}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
clean = sum(1 for m in manifest if m["clean"])
|
||||
print(f"scripts processed: {len(manifest)} (decode-clean: {clean}/{len(manifest)})")
|
||||
print(f"skipped (non-script magic): {len(skipped)} -> {[s[0] for s in skipped]}")
|
||||
print(f"disasm listings: build/disasm/*.asm")
|
||||
print(f"dialogue lines (show-text): {n_dialogue} -> build/text/dialogue.jsonl")
|
||||
print(f"all inline strings: {n_strings} -> build/text/strings.jsonl")
|
||||
print(f"manifest: build/manifest.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
199
tools/global_map.py
Normal file
199
tools/global_map.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Partial global-variable map — label raw global offsets by evidence (static, read-only).
|
||||
|
||||
Combines three static signals (see docs/name-resolution.md #2):
|
||||
1. *INIT writers — build/data/*.json name/desc/field bases ARE labelable global addresses.
|
||||
2. string anchors — set-string targets across the corpus = string tables.
|
||||
3. access shape — how each global is used: 2D-table base (+stride), 1D-array base,
|
||||
row-index (=> "current entity" pointer), or scalar.
|
||||
|
||||
Emits build/global-var-map.json (all evidence) + build/global-var-map.md (labelled subset).
|
||||
Usage: py -3.11 -X utf8 tools/global_map.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import collections
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
CORPUS = paths.DATA1
|
||||
DATA = ROOT / "build" / "data"
|
||||
OUT = ROOT / "build"
|
||||
|
||||
LOOKUP = 0x61 # lookup-array: (dst, base1d, idx)
|
||||
LOOKUP2D = 0x12c # lookup-array-2d: (dst, base2d, rowidx, stride, col)
|
||||
SET_STRING = 0x192 # (strbase, text)
|
||||
GLOBAL_TYPES = {3: "int", 4: "float", 5: "string", 6: "ptr", 8: "string-ptr"}
|
||||
|
||||
INIT_IDENTITY = {
|
||||
"SKINIT": "skill", "ITINIT": "item", "EBINIT": "unit", "CGINIT": "cg-gallery",
|
||||
"MPINIT": "map", "STINIT": "stage",
|
||||
}
|
||||
|
||||
|
||||
def from_init_tables():
|
||||
"""Label name/desc/field base addresses from the extracted *INIT JSONs."""
|
||||
labels = {} # addr -> (label, table, confidence, kind)
|
||||
for jf in sorted(DATA.glob("*.json")):
|
||||
try:
|
||||
d = json.loads(jf.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
tbl = d.get("table", jf.stem)
|
||||
ent = INIT_IDENTITY.get(tbl, tbl.lower())
|
||||
if d.get("name_array_base"):
|
||||
a = int(d["name_array_base"], 16)
|
||||
labels[a] = (f"{ent}-name-table", tbl, "high", "string-table")
|
||||
for k, v in (d.get("desc_array_bases") or {}).items():
|
||||
labels[int(v, 16)] = (f"{ent}-{k}-table", tbl, "high", "string-table")
|
||||
# field columns: classify dense (shared) vs sparse (per-entity extras)
|
||||
recs = d.get("records", [])
|
||||
freq = collections.Counter()
|
||||
for r in recs:
|
||||
for c in r.get("fields", {}):
|
||||
freq[c] += 1
|
||||
n = max(1, len(recs))
|
||||
for col, c in freq.items():
|
||||
a = int(col, 16)
|
||||
if a in labels:
|
||||
continue
|
||||
dense = c >= 0.5 * n
|
||||
labels[a] = (f"{ent}-field" + ("" if dense else "?"), tbl,
|
||||
"med" if dense else "low", "entity-field-array")
|
||||
return labels
|
||||
|
||||
|
||||
def scan_corpus():
|
||||
scrs = []
|
||||
for p in sorted(CORPUS.glob("*.BIN")):
|
||||
try:
|
||||
scrs.append(sys4load.load(p))
|
||||
except Exception:
|
||||
pass
|
||||
roles = collections.defaultdict(collections.Counter) # addr -> role -> count
|
||||
strides = collections.defaultdict(collections.Counter) # addr -> stride -> count
|
||||
gtype = {} # addr -> global type name
|
||||
rowidx = collections.Counter() # addr -> times used as 2D row index
|
||||
strtable_writers = collections.defaultdict(collections.Counter) # straddr -> script -> count
|
||||
for scr in scrs:
|
||||
sname = scr.path.stem
|
||||
for ins in scr.instructions:
|
||||
op = ins.opcode
|
||||
for pos, (t, v) in enumerate(ins.args):
|
||||
if t not in GLOBAL_TYPES:
|
||||
continue
|
||||
gtype[v] = GLOBAL_TYPES[t]
|
||||
if op == LOOKUP2D and pos == 1:
|
||||
roles[v]["2d-base"] += 1
|
||||
if len(ins.args) > 3 and ins.args[3][0] == 0: # stride immediate
|
||||
strides[v][ins.args[3][1]] += 1
|
||||
elif op == LOOKUP2D and pos == 2:
|
||||
roles[v]["row-index"] += 1
|
||||
rowidx[v] += 1
|
||||
elif op == LOOKUP and pos == 1:
|
||||
roles[v]["1d-base"] += 1
|
||||
elif op == SET_STRING and pos == 0:
|
||||
roles[v]["string-table"] += 1
|
||||
strtable_writers[v][sname] += 1
|
||||
else:
|
||||
roles[v]["scalar"] += 1
|
||||
return roles, strides, gtype, rowidx, strtable_writers, len(scrs)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
init_labels = from_init_tables()
|
||||
roles, strides, gtype, rowidx, strwriters, nscr = scan_corpus()
|
||||
all_addrs = set(roles) | set(init_labels)
|
||||
|
||||
# merge into per-address records
|
||||
entries = {}
|
||||
for a in sorted(all_addrs):
|
||||
r = dict(roles.get(a, {}))
|
||||
total = sum(r.values())
|
||||
st = sorted(strides.get(a, {}), key=lambda s: -strides[a][s])
|
||||
label = conf = kind = None
|
||||
if a in init_labels:
|
||||
label, tbl, conf, kind = init_labels[a]
|
||||
elif r.get("string-table"):
|
||||
w = strwriters.get(a, {})
|
||||
top = max(w, key=w.get) if w else "?"
|
||||
label, conf, kind = f"string-table (written by {top})", "med", "string-table"
|
||||
elif r.get("2d-base"):
|
||||
stride = st[0] if st else "?"
|
||||
label, conf, kind = f"record-table[stride {stride}]", "med", "record-table-2d"
|
||||
elif r.get("1d-base"):
|
||||
label, conf, kind = "array", "low", "array-1d"
|
||||
elif r.get("row-index"): # used as a 2D row index but never a base -> an index var
|
||||
ri, sc = r["row-index"], r.get("scalar", 0)
|
||||
pure = ri / (ri + sc) if (ri + sc) else 0
|
||||
if pure >= 0.3:
|
||||
label, conf, kind = "current-entity-index?", "med", "index"
|
||||
else:
|
||||
label, conf, kind = "index/counter?", "low", "index"
|
||||
entries[a] = dict(addr=f"0x{a:x}", type=gtype.get(a, "?"), uses=total,
|
||||
roles=r, stride_candidates=[f"0x{s:x}" for s in st[:3]] or None,
|
||||
label=label, confidence=conf, kind=kind, table=(init_labels.get(a, (None, None))[1]))
|
||||
|
||||
# "current entity" pointers: globals used as 2D row index, ranked by purity
|
||||
def purity(a):
|
||||
ri, sc = rowidx[a], roles[a].get("scalar", 0)
|
||||
return ri / (ri + sc) if (ri + sc) else 0
|
||||
current_idx = [dict(addr=f"0x{a:x}", used_as_row_index=rowidx[a],
|
||||
also_scalar=roles[a].get("scalar", 0), purity=round(purity(a), 2))
|
||||
for a in sorted(rowidx, key=lambda a: (-round(purity(a), 2), -rowidx[a]))
|
||||
if rowidx[a] >= 10][:15]
|
||||
|
||||
labeled = {k: v for k, v in entries.items() if v["label"]}
|
||||
out = {
|
||||
"generated_from": f"extracted/DATA1 ({nscr} scripts) + build/data/*.json",
|
||||
"note": "Static partial map. Labels ending '?' are low-confidence. Column addresses "
|
||||
"are raw engine globals; Frida can confirm the ambiguous ones (see docs/name-resolution.md).",
|
||||
"totals": {"distinct_globals_seen": len(all_addrs), "labelled": len(labeled),
|
||||
"from_init_tables": len(init_labels)},
|
||||
"current_entity_index_candidates": current_idx,
|
||||
"globals": {v["addr"]: {k: val for k, val in v.items() if k != "addr"}
|
||||
for v in entries.values()},
|
||||
}
|
||||
(OUT / "global-var-map.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# readable markdown of the labelled subset
|
||||
md = ["# Partial global-variable map", "",
|
||||
f"Static map from {nscr} scripts + `build/data/*.json`. "
|
||||
f"**{len(labeled)} of {len(all_addrs)} distinct globals labelled.** "
|
||||
"Regenerate: `py -3.11 -X utf8 tools/global_map.py`. See `docs/name-resolution.md`.", "",
|
||||
"## 'Current entity' index globals (dominant 2D row-index)", "",
|
||||
"The row index into per-entity tables — the VM's \"which unit/entity are we on\" pointers. "
|
||||
"Ranked by purity (fraction of uses that are row-index vs. general scalar); high purity = "
|
||||
"a dedicated index pointer, low = a general-purpose var reused as an index.", "",
|
||||
"| global | row-index uses | also scalar | purity |", "|---|---|---|---|"]
|
||||
for c in current_idx:
|
||||
md.append(f"| `{c['addr']}` | {c['used_as_row_index']} | {c['also_scalar']} | {c['purity']} |")
|
||||
for kind, title in [("string-table", "String tables (names / descriptions / messages)"),
|
||||
("entity-field-array", "Per-entity data-field arrays (from *INIT)"),
|
||||
("record-table-2d", "Row-major record tables (from access shape)")]:
|
||||
rows = sorted((v for v in labeled.values() if v["kind"] == kind), key=lambda v: -v["uses"])
|
||||
md += ["", f"## {title} ({len(rows)})", "", "| global | type | label | conf | uses | stride |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for v in rows[:40]:
|
||||
md.append(f"| `{v['addr']}` | {v['type']} | {v['label']} | {v['confidence']} | "
|
||||
f"{v['uses']} | {(v['stride_candidates'] or ['—'])[0]} |")
|
||||
if len(rows) > 40:
|
||||
md.append(f"| … | | +{len(rows)-40} more (see JSON) | | | |")
|
||||
(OUT / "global-var-map.md").write_text("\n".join(md), encoding="utf-8")
|
||||
|
||||
print(f"distinct globals seen: {len(all_addrs)}; labelled: {len(labeled)} "
|
||||
f"({len(init_labels)} from *INIT)")
|
||||
print(f"top 'current entity' index globals: " +
|
||||
", ".join(f"{c['addr']}({c['used_as_row_index']})" for c in current_idx[:5]))
|
||||
print(f"-> build/global-var-map.json, build/global-var-map.md")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
131
tools/opcode_context.py
Normal file
131
tools/opcode_context.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evidence gatherer for classifying unnamed AGE opcodes.
|
||||
|
||||
For an opcode (or the top-N unnamed ones) prints: frequency + share + cumulative coverage,
|
||||
argc, operand-type signature histogram, most common predecessor/successor opcodes, a few
|
||||
real disassembly snippets, and Kelebek's inline comment (from the upstream cpp). Read-only.
|
||||
|
||||
Usage:
|
||||
py -3.11 -X utf8 tools/opcode_context.py --top 20 # ranked unnamed summary + coverage
|
||||
py -3.11 -X utf8 tools/opcode_context.py 0x1f4 0x71 0x7a # detailed evidence per opcode
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
import collections
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import paths
|
||||
import sys4load
|
||||
import age_opcodes as ao
|
||||
|
||||
CORPUS = paths.DATA1
|
||||
KELEBEK_CPP = ROOT / "vm-map" / "kelebek1-age-shared.cpp"
|
||||
|
||||
|
||||
def is_unnamed(op: int) -> bool:
|
||||
lbl = ao.OPCODES.get(op, ("?", 0))[0]
|
||||
return bool(re.fullmatch(r"u[0-9A-Fa-f]{8}", lbl)) or lbl.lower() == f"{op:x}" or lbl.startswith("dev_ukn")
|
||||
|
||||
|
||||
def label(op: int) -> str:
|
||||
return ao.OPCODES.get(op, (f"?{op:x}", 0))[0]
|
||||
|
||||
|
||||
def kelebek_comments() -> dict[int, str]:
|
||||
out = {}
|
||||
if not KELEBEK_CPP.exists():
|
||||
return out
|
||||
for m in re.finditer(r"\{\s*(0x[0-9A-Fa-f]+)\s*,\s*\"[^\"]*\"\s*,\s*0x[0-9A-Fa-f]+\s*\}\s*,?\s*//\s*(.*)",
|
||||
KELEBEK_CPP.read_text(encoding="utf-8")):
|
||||
out[int(m.group(1), 16)] = m.group(2).strip()
|
||||
return out
|
||||
|
||||
|
||||
def load_corpus():
|
||||
scrs = []
|
||||
for p in sorted(CORPUS.glob("*.BIN")):
|
||||
try:
|
||||
scrs.append(sys4load.load(p))
|
||||
except Exception:
|
||||
pass
|
||||
return scrs
|
||||
|
||||
|
||||
def fmt_instr(scr, ins) -> str:
|
||||
ops = " ".join(sys4load._fmt_operand(ins.opcode, i, t, v, scr.strings)
|
||||
for i, (t, v) in enumerate(ins.args))
|
||||
return f"{label(ins.opcode)}{(' ' + ops) if ops else ''}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
scrs = load_corpus()
|
||||
freq = collections.Counter()
|
||||
total = 0
|
||||
for scr in scrs:
|
||||
for ins in scr.instructions:
|
||||
freq[ins.opcode] += 1
|
||||
total += 1
|
||||
named_vol = sum(c for op, c in freq.items() if not is_unnamed(op))
|
||||
comments = kelebek_comments()
|
||||
|
||||
if args and args[0] == "--top":
|
||||
n = int(args[1]) if len(args) > 1 else 20
|
||||
unnamed = [(op, c) for op, c in freq.most_common() if is_unnamed(op)]
|
||||
print(f"corpus {len(scrs)} scripts, {total} instructions; "
|
||||
f"named coverage {100*named_vol/total:.2f}%; {len(unnamed)} distinct unnamed ops")
|
||||
print(f"{'#':>3} {'op':<7}{'argc':>5}{'count':>9}{'share':>8}{'cum-cov':>9} kelebek-comment")
|
||||
cum = named_vol
|
||||
for i, (op, c) in enumerate(unnamed[:n]):
|
||||
cum += c
|
||||
argc = ao.OPCODES.get(op, ("", 0))[1]
|
||||
print(f"{i+1:>3} 0x{op:<5x}{argc:>5}{c:>9}{100*c/total:>7.2f}%{100*cum/total:>8.2f}% {comments.get(op,'')[:48]}")
|
||||
return 0
|
||||
|
||||
# detailed per-op evidence
|
||||
targets = [int(a, 16) for a in args] if args else [op for op, _ in
|
||||
[(op, c) for op, c in freq.most_common() if is_unnamed(op)][:8]]
|
||||
# precompute neighbour + signature stats
|
||||
pred = collections.defaultdict(collections.Counter)
|
||||
succ = collections.defaultdict(collections.Counter)
|
||||
sig = collections.defaultdict(collections.Counter)
|
||||
locs = collections.defaultdict(list) # op -> [(scr, index)]
|
||||
for scr in scrs:
|
||||
ins = scr.instructions
|
||||
for i, x in enumerate(ins):
|
||||
if x.opcode in targets:
|
||||
if i > 0:
|
||||
pred[x.opcode][label(ins[i-1].opcode)] += 1
|
||||
if i+1 < len(ins):
|
||||
succ[x.opcode][label(ins[i+1].opcode)] += 1
|
||||
sig[x.opcode][tuple(ao.ARG_TYPES.get(t, hex(t)) for t, _ in x.args)] += 1
|
||||
if len(locs[x.opcode]) < 4:
|
||||
locs[x.opcode].append((scr, i))
|
||||
|
||||
for op in targets:
|
||||
c = freq.get(op, 0)
|
||||
argc = ao.OPCODES.get(op, ("", 0))[1]
|
||||
print(f"\n{'='*72}\nopcode 0x{op:x} label={label(op)} argc={argc} "
|
||||
f"count={c} ({100*c/total:.2f}% of instrs)")
|
||||
if comments.get(op):
|
||||
print(f" kelebek-comment: {comments[op]}")
|
||||
print(f" operand-type signatures: " +
|
||||
", ".join(f"{'/'.join(s) if s else 'none'}×{n}" for s, n in sig[op].most_common(4)))
|
||||
print(f" top predecessors: " + ", ".join(f"{k}×{v}" for k, v in pred[op].most_common(5)))
|
||||
print(f" top successors: " + ", ".join(f"{k}×{v}" for k, v in succ[op].most_common(5)))
|
||||
print(f" snippets:")
|
||||
for scr, i in locs[op]:
|
||||
lo, hi = max(0, i-2), min(len(scr.instructions), i+3)
|
||||
for j in range(lo, hi):
|
||||
mark = ">>" if j == i else " "
|
||||
print(f" {mark} [{scr.path.name}] {fmt_instr(scr, scr.instructions[j])}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
41
tools/pack_check.py
Normal file
41
tools/pack_check.py
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import os, sys, struct, math, re
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import paths
|
||||
data = paths.AGE_EXE.read_bytes()
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
nsec = struct.unpack_from("<H", data, pe+6)[0]
|
||||
opt = pe+24
|
||||
nrva = struct.unpack_from("<I", data, opt+92)[0]
|
||||
# data directories: index 1 = import, 12 = IAT
|
||||
imp_rva, imp_sz = struct.unpack_from("<II", data, opt+96+1*8)
|
||||
iat_rva, iat_sz = struct.unpack_from("<II", data, opt+96+12*8)
|
||||
print(f"NumberOfRvaAndSizes {nrva}")
|
||||
print(f"ImportDir rva {imp_rva:#x} size {imp_sz:#x}")
|
||||
print(f"IAT rva {iat_rva:#x} size {iat_sz:#x}")
|
||||
sec_off = opt + struct.unpack_from("<H", data, pe+20)[0]
|
||||
print("\nsection entropy:")
|
||||
for i in range(nsec):
|
||||
o=sec_off+i*40
|
||||
name=data[o:o+8].rstrip(b"\0").decode('ascii','replace') or f"<blank{i}>"
|
||||
vsize,va,rsize,raw=struct.unpack_from("<IIII",data,o+8)
|
||||
chunk=data[raw:raw+rsize]
|
||||
if chunk:
|
||||
freq=[0]*256
|
||||
for b in chunk: freq[b]+=1
|
||||
ent=-sum((c/len(chunk))*math.log2(c/len(chunk)) for c in freq if c)
|
||||
else:
|
||||
ent=0
|
||||
print(f" {name:<10} va {va:#08x} vsize {vsize:#08x} raw {raw:#08x} rsize {rsize:#08x} entropy {ent:.2f}")
|
||||
|
||||
# UTF-16LE anchor search
|
||||
print("\nUTF-16 anchors:")
|
||||
for t in [b"SYS4422", b"DATA1", b"Eushully", b".BIN", b"AGE"]:
|
||||
u = t.decode().encode("utf-16le")
|
||||
n = data.count(u)
|
||||
print(f" {t.decode():10} utf16 hits {n}")
|
||||
# ascii import dll names still present?
|
||||
print("\nDLL-name-ish ascii:")
|
||||
for m in re.finditer(rb"[\x20-\x7e]{4,}\.dll", data, re.I):
|
||||
print(" ", m.group().decode(errors='replace'))
|
||||
50
tools/paths.py
Normal file
50
tools/paths.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Central path anchor for the age-reimpl workspace.
|
||||
|
||||
Every path below is derived from this file's own location, so the whole tree can
|
||||
be relocated without editing any tool -- there are no hard-coded drive paths.
|
||||
|
||||
Workspace layout (siblings under the workspace root):
|
||||
|
||||
<workspace>/ e.g. S:\\Game Hacking\\Eushully\\Himegari
|
||||
姫狩りダンジョンマイスター/ pristine game install (AGE.EXE, *.ALF,
|
||||
loose *.BIN patch-overrides, DLLs)
|
||||
extracted/ extracted ALF data: DATA1 .. DATA5
|
||||
age-reimpl/ our work (this repo)
|
||||
tools/ build/ docs/ vm-map/ godot/ bin/
|
||||
|
||||
To point the tools at a different install, change GAME_DIR / EXTRACTED here only.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent # age-reimpl/
|
||||
WORKSPACE = REPO.parent # workspace root
|
||||
GAME_DIR = WORKSPACE / "姫狩りダンジョンマイスター" # pristine game install
|
||||
EXTRACTED = WORKSPACE / "extracted" # extracted ALF archives
|
||||
DATA1 = EXTRACTED / "DATA1" # the .BIN script corpus
|
||||
BUILD = REPO / "build" # derived corpora (regenerable)
|
||||
VM_MAP = REPO / "vm-map"
|
||||
BIN = REPO / "bin" # 3rd-party tools (BinExtractALF, ...)
|
||||
AGE_EXE = GAME_DIR / "AGE.EXE"
|
||||
KELEBEK_CPP = VM_MAP / "kelebek1-age-shared.cpp"
|
||||
|
||||
|
||||
def add_self_to_syspath():
|
||||
"""Let a standalone script `import paths` / `import sys4load` from tools/."""
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def scripts():
|
||||
"""Authoritative {UPPERNAME.BIN -> path} map for the script corpus.
|
||||
|
||||
Loose *.BIN patch-overrides in the game dir shadow their extracted/DATA1
|
||||
copies (runtime behaviour), so they win on name collision.
|
||||
"""
|
||||
files = {}
|
||||
if DATA1.is_dir():
|
||||
for p in sorted(DATA1.glob("*.BIN")):
|
||||
files[p.name.upper()] = p
|
||||
for p in sorted(GAME_DIR.glob("*.BIN")): # overrides win
|
||||
files[p.name.upper()] = p
|
||||
return files
|
||||
69
tools/probe_header.py
Normal file
69
tools/probe_header.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Probe SYS4 script headers across the DATA1 corpus.
|
||||
|
||||
Header hypothesis (offsets in bytes):
|
||||
0x00 char magic[8] "SYS4422 "
|
||||
0x08 u32 F0..F12 13 fields
|
||||
0x3C body (dword stream)
|
||||
|
||||
Tests invariants and prints field stats.
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(path):
|
||||
data = path.read_bytes()
|
||||
magic = data[:8]
|
||||
fields = struct.unpack_from("<13I", data, 8)
|
||||
return data, magic, fields
|
||||
|
||||
def main():
|
||||
files = sorted(ROOT.glob("*.BIN"))
|
||||
print(f"files: {len(files)}")
|
||||
|
||||
magics = Counter()
|
||||
distinct = [Counter() for _ in range(13)]
|
||||
fail_f12 = [] # F12 != filedwords - 15
|
||||
fail_f6 = [] # F6 != 0x1C
|
||||
fail_f11 = [] # F11 != 0
|
||||
rel_a = Counter() # F8 + F9 vs F12
|
||||
rel_b = Counter() # F10 + F11 vs F12
|
||||
body_first = Counter()
|
||||
|
||||
for p in files:
|
||||
data, magic, f = parse(p)
|
||||
magics[magic] += 1
|
||||
nd = len(data) // 4
|
||||
for i, v in enumerate(f):
|
||||
distinct[i][v] += 1
|
||||
if f[12] != nd - 15:
|
||||
fail_f12.append((p.name, f[12], nd - 15))
|
||||
if f[6] != 0x1C:
|
||||
fail_f6.append((p.name, f[6]))
|
||||
if f[11] != 0:
|
||||
fail_f11.append((p.name, f[10], f[11], f[12]))
|
||||
rel_a["F8+F9==F12" if f[8] + f[9] == f[12] else
|
||||
("F8+F9<F12" if f[8] + f[9] < f[12] else "F8+F9>F12")] += 1
|
||||
rel_b["F10+F11==F12" if f[10] + f[11] == f[12] else
|
||||
("F10+F11<F12" if f[10] + f[11] < f[12] else "F10+F11>F12")] += 1
|
||||
if len(data) >= 0x40:
|
||||
body_first[struct.unpack_from("<I", data, 0x3C)[0]] += 1
|
||||
|
||||
print("magics:", dict(magics))
|
||||
print("\nfield value diversity (distinct count; top values):")
|
||||
for i, c in enumerate(distinct):
|
||||
top = ", ".join(f"{v:#x}x{n}" for v, n in c.most_common(4))
|
||||
print(f" F{i:<2} @0x{8+i*4:02X}: {len(c):4} distinct | {top}")
|
||||
print(f"\nF6==0x1C fails: {len(fail_f6)} {fail_f6[:5]}")
|
||||
print(f"F12==dwords-15 fails: {len(fail_f12)} {fail_f12[:5]}")
|
||||
print(f"F11!=0 count: {len(fail_f11)} {fail_f11[:5]}")
|
||||
print("rel F8+F9 vs F12:", dict(rel_a))
|
||||
print("rel F10+F11 vs F12:", dict(rel_b))
|
||||
print("\nfirst body dword (top 15):",
|
||||
", ".join(f"{v:#x}x{n}" for v, n in body_first.most_common(15)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
tools/probe_leads.py
Normal file
74
tools/probe_leads.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Corpus-wide candidate-opcode histogram.
|
||||
|
||||
T3 entries point at [0x8F, 0, line#] records embedded in CODE. The dword right
|
||||
after each 3-dword T3 record is the lead of the next statement -> candidate
|
||||
opcode. Histogram those leads over all root-override + DATA1 scripts.
|
||||
Also: histogram the dword immediately preceding each `0x02 <off>` string ref
|
||||
(candidate "takes-a-string" opcodes), with example files.
|
||||
"""
|
||||
import os, sys, collections
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
# authoritative set: game-dir overrides shadow extracted/DATA1
|
||||
files = paths.scripts()
|
||||
print(f"{len(files)} scripts (overrides win)")
|
||||
|
||||
lead_hist = collections.Counter() # dword after each T3 record
|
||||
lead_examples = collections.defaultdict(set)
|
||||
pre_str = collections.Counter() # dword before each 0x02 <off> ref
|
||||
pre_str_ex = collections.defaultdict(set)
|
||||
first_dword = collections.Counter()
|
||||
t3_rec_shape_ok = 0
|
||||
t3_rec_total = 0
|
||||
bad = 0
|
||||
|
||||
for name, p in sorted(files.items()):
|
||||
try:
|
||||
scr = sys4load.load(p)
|
||||
except Exception as e:
|
||||
bad += 1
|
||||
continue
|
||||
dw = scr.dwords
|
||||
code_len = scr.code_len
|
||||
first_dword[dw[0]] += 1
|
||||
# statement leads via T3 records
|
||||
for e in scr.table_entries("T3"):
|
||||
if not (0 <= e < code_len):
|
||||
continue
|
||||
t3_rec_total += 1
|
||||
if e + 2 < code_len and dw[e] == 0x8F and dw[e+1] == 0:
|
||||
t3_rec_shape_ok += 1
|
||||
nxt = e + 3
|
||||
if nxt < code_len:
|
||||
v = dw[nxt]
|
||||
lead_hist[v] += 1
|
||||
if len(lead_examples[v]) < 3:
|
||||
lead_examples[v].add(name)
|
||||
# dword preceding string refs (ref index i+1 holds offset; tag at i)
|
||||
for ref_idx in scr.string_refs: # ref_idx points at the offset dword
|
||||
tag_idx = ref_idx - 1 # the 0x02
|
||||
if tag_idx - 1 >= 0:
|
||||
v = dw[tag_idx - 1]
|
||||
pre_str[v] += 1
|
||||
if len(pre_str_ex[v]) < 3:
|
||||
pre_str_ex[v].add(name)
|
||||
|
||||
print(f"parse failures: {bad}")
|
||||
print(f"T3 records with shape [0x8F,0,*]: {t3_rec_shape_ok}/{t3_rec_total}")
|
||||
print("\n== first body dword ==")
|
||||
for v, c in first_dword.most_common(8):
|
||||
print(f" {v:#06x} {c}")
|
||||
print(f"\n== statement leads after T3 records (top 40 of {len(lead_hist)}) ==")
|
||||
total = sum(lead_hist.values())
|
||||
for v, c in lead_hist.most_common(40):
|
||||
ex = ",".join(sorted(lead_examples[v]))
|
||||
print(f" {v:#06x} {c:>7} ({100*c/total:5.2f}%) e.g. {ex}")
|
||||
print(f"\n== dword preceding `0x02 <off>` string refs (top 25 of {len(pre_str)}) ==")
|
||||
tot2 = sum(pre_str.values())
|
||||
for v, c in pre_str.most_common(25):
|
||||
ex = ",".join(sorted(pre_str_ex[v]))
|
||||
print(f" {v:#06x} {c:>7} ({100*c/tot2:5.2f}%) e.g. {ex}")
|
||||
76
tools/probe_refs.py
Normal file
76
tools/probe_refs.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""What do T1/T2/T3 entries point at? Plus: extract XOR-0xFF strings from scripts.
|
||||
|
||||
Prints dword windows around table-entry targets, and scans for complement-encoded
|
||||
Shift-JIS strings.
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(path):
|
||||
data = path.read_bytes()
|
||||
f = struct.unpack_from("<13I", data, 8)
|
||||
body = data[0x3C:]
|
||||
return data, f, body
|
||||
|
||||
def dw(body, i):
|
||||
return struct.unpack_from("<I", body, i * 4)[0]
|
||||
|
||||
def try_string(body, i, max_dwords=40):
|
||||
"""Try to decode a XOR-0xFF cp932 string starting at dword i."""
|
||||
raw = bytearray()
|
||||
n = 0
|
||||
for j in range(i, min(i + max_dwords, len(body) // 4)):
|
||||
chunk = bytes(b ^ 0xFF for b in body[j*4:(j+1)*4])
|
||||
raw += chunk
|
||||
n += 1
|
||||
if 0 in chunk:
|
||||
break
|
||||
else:
|
||||
return None, 0
|
||||
s = raw.split(b"\0")[0]
|
||||
if len(s) == 0:
|
||||
return "", n
|
||||
try:
|
||||
dec = s.decode("cp932")
|
||||
except UnicodeDecodeError:
|
||||
return None, 0
|
||||
if all(0x20 <= b or b in (0x09,) for b in s):
|
||||
return dec, n
|
||||
return None, 0
|
||||
|
||||
def windows(name):
|
||||
data, f, body = parse(ROOT / name)
|
||||
nd = len(body) // 4
|
||||
print(f"\n=== {name} (body {nd} dw, code {f[8]} dw) ===")
|
||||
for label, cnt, off in (("T1", f[7], f[8]), ("T2", f[9], f[10]), ("T3", f[11], f[12])):
|
||||
entries = [dw(body, off + k) for k in range(min(cnt, 6))]
|
||||
print(f" {label}: {cnt} entries -> {[hex(e) for e in entries]}")
|
||||
for e in entries[:4]:
|
||||
ctx = [dw(body, i) for i in range(max(0, e - 2), min(nd, e + 5))]
|
||||
s, _ = try_string(body, e)
|
||||
tag = f" str@target: {s!r}" if s else ""
|
||||
print(f" target {e:#x}: [-2..+4] = {[hex(v) for v in ctx]}{tag}")
|
||||
|
||||
def scan_strings(name, limit=15, min_len=4):
|
||||
data, f, body = parse(ROOT / name)
|
||||
nd = len(body) // 4
|
||||
print(f"\n=== strings in {name} ===")
|
||||
found = 0
|
||||
i = 0
|
||||
while i < nd and found < limit:
|
||||
s, n = try_string(body, i)
|
||||
if s and len(s) >= min_len:
|
||||
print(f" [{i:#x}] {s!r}")
|
||||
found += 1
|
||||
i += n
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
for n in ("MENU.BIN", "ADDEXP.BIN", "CALLBACK_LOST.BIN", "ALCHEMY.BIN"):
|
||||
windows(n)
|
||||
scan_strings("MENU.BIN")
|
||||
scan_strings("SC0030.BIN", limit=20, min_len=6)
|
||||
76
tools/probe_tables.py
Normal file
76
tools/probe_tables.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Verify the three (count, offset) table descriptors and inspect table contents.
|
||||
|
||||
Layout hypothesis (all offsets in dwords, relative to body start at 0x3C):
|
||||
code: body[0 .. F8)
|
||||
T1: count=F7, offset=F8, entry size s1 (solve: F8 + s1*F7 == F10)
|
||||
T2: count=F9, offset=F10, entry size s2 (solve: F10 + s2*F9 == F12)
|
||||
T3: count=F11, offset=F12, entry size s3 (solve: F12 + s3*F11 == EOF)
|
||||
"""
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(path):
|
||||
data = path.read_bytes()
|
||||
f = struct.unpack_from("<13I", data, 8)
|
||||
return data, f
|
||||
|
||||
def solve_entry_size(gap_pairs):
|
||||
"""gap_pairs: list of (count, gap_dwords). Return consistent entry size or None."""
|
||||
sizes = set()
|
||||
for count, gap in gap_pairs:
|
||||
if count:
|
||||
if gap % count:
|
||||
return f"non-integer ({count},{gap})"
|
||||
sizes.add(gap // count)
|
||||
return sizes
|
||||
|
||||
def main():
|
||||
files = sorted(ROOT.glob("*.BIN"))
|
||||
t1_pairs, t2_pairs, t3_pairs = [], [], []
|
||||
bad = []
|
||||
for p in files:
|
||||
data, f = parse(p)
|
||||
nd = len(data) // 4 - 15 # body dwords
|
||||
F7, F8, F9, F10, F11, F12 = f[7], f[8], f[9], f[10], f[11], f[12]
|
||||
if not (F8 <= F10 <= F12 <= nd):
|
||||
bad.append((p.name, F8, F10, F12, nd))
|
||||
continue
|
||||
t1_pairs.append((F7, F10 - F8))
|
||||
t2_pairs.append((F9, F12 - F10))
|
||||
t3_pairs.append((F11, nd - F12))
|
||||
print(f"ordering violations: {len(bad)} {bad[:5]}")
|
||||
print("T1 entry sizes:", solve_entry_size(t1_pairs))
|
||||
print("T2 entry sizes:", solve_entry_size(t2_pairs))
|
||||
print("T3 entry sizes:", solve_entry_size(t3_pairs))
|
||||
|
||||
# zero-count but nonzero-gap sanity
|
||||
for name, pairs in (("T1", t1_pairs), ("T2", t2_pairs), ("T3", t3_pairs)):
|
||||
odd = sum(1 for c, g in pairs if c == 0 and g != 0)
|
||||
print(f"{name}: count==0 but gap!=0 in {odd} files")
|
||||
|
||||
# F2 outliers
|
||||
print("\nF2 outliers:")
|
||||
for p in files:
|
||||
_, f = parse(p)
|
||||
if f[2] not in (1, 2):
|
||||
print(f" {p.name}: F2={f[2]:#x}")
|
||||
|
||||
# dump tables for a few files
|
||||
for name in ("MENU.BIN", "ADDEXP.BIN", "ADDILL.BIN", "ALCHEMY.BIN"):
|
||||
data, f = parse(ROOT / name)
|
||||
nd = len(data) // 4 - 15
|
||||
print(f"\n=== {name}: body={nd} F7-12: cnt/off T1={f[7]}/{f[8]} "
|
||||
f"T2={f[9]}/{f[10]} T3={f[11]}/{f[12]}")
|
||||
for label, cnt, off, end in (("T1", f[7], f[8], f[10]),
|
||||
("T2", f[9], f[10], f[12]),
|
||||
("T3", f[11], f[12], nd)):
|
||||
vals = struct.unpack_from(f"<{end-off}I", data, 0x3C + off*4)
|
||||
show = ", ".join(f"{v:#x}" for v in vals[:16])
|
||||
print(f" {label} ({cnt} entries, {end-off} dwords): {show}"
|
||||
+ (" ..." if end-off > 16 else ""))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
76
tools/probe_tags.py
Normal file
76
tools/probe_tags.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Confirm operand tag hypothesis: string pointers are preceded by tag dword 2.
|
||||
Also profile the first body dword and the recurring 'type' dwords 0x55/0x6E/0x6F/0x71/0x72/0x8F.
|
||||
"""
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(p):
|
||||
data = p.read_bytes()
|
||||
f = struct.unpack_from("<13I", data, 8)
|
||||
body = data[0x3C:]
|
||||
n = len(body) // 4
|
||||
dws = struct.unpack_from(f"<{n}I", body, 0)
|
||||
return f, dws, body
|
||||
|
||||
def is_string_at(body, i):
|
||||
n = len(body) // 4
|
||||
if i >= n:
|
||||
return False
|
||||
raw = bytearray()
|
||||
for j in range(i, min(i + 60, n)):
|
||||
chunk = bytes(b ^ 0xFF for b in body[j*4:(j+1)*4])
|
||||
raw += chunk
|
||||
if 0 in chunk:
|
||||
break
|
||||
else:
|
||||
return False
|
||||
s = bytes(raw).split(b"\0")[0]
|
||||
if len(s) < 2:
|
||||
return False
|
||||
i2, chars = 0, 0
|
||||
while i2 < len(s):
|
||||
b = s[i2]
|
||||
if 0x20 <= b <= 0x7E:
|
||||
i2 += 1; chars += 1
|
||||
elif 0x81 <= b <= 0x9F or 0xE0 <= b <= 0xEA:
|
||||
if i2 + 1 < len(s) and 0x40 <= s[i2+1] <= 0xFC and s[i2+1] != 0x7F:
|
||||
i2 += 2; chars += 1
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
return chars >= 2
|
||||
|
||||
def main():
|
||||
files = sorted(ROOT.glob("*.BIN"))
|
||||
preceding = Counter() # dword right before a string-region start
|
||||
first_dword = Counter()
|
||||
total_strings = 0
|
||||
for p in files:
|
||||
f, dws, body = parse(p)
|
||||
n = len(dws)
|
||||
first_dword[dws[0]] += 1
|
||||
# find string regions in the string area (>= T3 end is where strings live typically)
|
||||
i = 1
|
||||
while i < n:
|
||||
if is_string_at(body, i) and not is_string_at(body, i - 1):
|
||||
total_strings += 1
|
||||
preceding[dws[i - 1]] += 1
|
||||
# skip the string
|
||||
while i < n and is_string_at(body, i):
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
print(f"total string regions: {total_strings}")
|
||||
print("dword immediately preceding a string (top 10):")
|
||||
for v, c in preceding.most_common(10):
|
||||
print(f" {v:#x}: {c} ({100*c/total_strings:.1f}%)")
|
||||
print("\nfirst body dword (top 8):")
|
||||
for v, c in first_dword.most_common(8):
|
||||
print(f" {v:#x}: {c}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
tools/probe_types.py
Normal file
101
tools/probe_types.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Corpus-wide: verify T1/T2/T3 target-dword types, test table completeness,
|
||||
and do a stricter string scan.
|
||||
"""
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(path):
|
||||
data = path.read_bytes()
|
||||
f = struct.unpack_from("<13I", data, 8)
|
||||
body = data[0x3C:]
|
||||
n = len(body) // 4
|
||||
dws = struct.unpack_from(f"<{n}I", body, 0)
|
||||
return f, dws, body
|
||||
|
||||
def main():
|
||||
files = sorted(ROOT.glob("*.BIN"))
|
||||
type_hist = {1: Counter(), 2: Counter(), 3: Counter()}
|
||||
complete = {1: Counter(), 2: Counter(), 3: Counter()}
|
||||
for p in files:
|
||||
f, dws, body = parse(p)
|
||||
code_end = f[8]
|
||||
specs = {1: (f[7], f[8], 0x71), 2: (f[9], f[10], 0x03), 3: (f[11], f[12], 0x8F)}
|
||||
for t, (cnt, off, expect) in specs.items():
|
||||
targets = dws[off:off + cnt]
|
||||
for e in targets:
|
||||
type_hist[t][dws[e] if e < len(dws) else "OOB"] += 1
|
||||
# naive count of (expect, 0, X) triples in code, at any alignment
|
||||
naive = sum(1 for i in range(code_end - 2)
|
||||
if dws[i] == expect and dws[i + 1] == 0)
|
||||
if cnt == naive:
|
||||
complete[t]["exact"] += 1
|
||||
elif cnt < naive:
|
||||
complete[t]["table<naive"] += 1
|
||||
else:
|
||||
complete[t]["table>naive"] += 1
|
||||
for t in (1, 2, 3):
|
||||
print(f"T{t} target-dword histogram: "
|
||||
+ ", ".join(f"{v:#x}x{n}" for v, n in type_hist[t].most_common(6)))
|
||||
print(f"T{t} completeness vs naive scan: {dict(complete[t])}")
|
||||
|
||||
# value ranges of the operands referenced by each table (sample corpus-wide)
|
||||
for t, expect in ((1, 0x71), (2, 0x03), (3, 0x8F)):
|
||||
lo, hi = None, None
|
||||
vals = Counter()
|
||||
for p in files:
|
||||
f, dws, body = parse(p)
|
||||
cnt, off = (f[7], f[8]) if t == 1 else (f[9], f[10]) if t == 2 else (f[11], f[12])
|
||||
for e in dws[off:off + cnt]:
|
||||
if e + 2 < len(dws):
|
||||
v = dws[e + 2]
|
||||
vals[v] += 1
|
||||
lo = v if lo is None else min(lo, v)
|
||||
hi = v if hi is None else max(hi, v)
|
||||
print(f"T{t} operand values: min={lo and hex(lo)}, max={hi and hex(hi)}, "
|
||||
f"top: {', '.join(f'{v:#x}x{n}' for v, n in vals.most_common(8))}")
|
||||
|
||||
def good_string(bs):
|
||||
"""bs = decoded-candidate raw bytes (already XOR'd). Strict cp932 validity."""
|
||||
i, chars = 0, 0
|
||||
while i < len(bs):
|
||||
b = bs[i]
|
||||
if 0x20 <= b <= 0x7E:
|
||||
i += 1; chars += 1
|
||||
elif 0x81 <= b <= 0x9F or 0xE0 <= b <= 0xEA:
|
||||
if i + 1 < len(bs) and (0x40 <= bs[i+1] <= 0xFC) and bs[i+1] != 0x7F:
|
||||
i += 2; chars += 1
|
||||
else:
|
||||
return 0
|
||||
else:
|
||||
return 0
|
||||
return chars
|
||||
|
||||
def scan(name, limit=25, min_chars=3):
|
||||
f, dws, body = parse(ROOT / name)
|
||||
n = len(body) // 4
|
||||
print(f"\n=== strings in {name} (strict) ===")
|
||||
found, i = 0, 0
|
||||
while i < n and found < limit:
|
||||
raw = bytearray()
|
||||
j = i
|
||||
while j < n:
|
||||
chunk = bytes(b ^ 0xFF for b in body[j*4:(j+1)*4])
|
||||
raw += chunk
|
||||
j += 1
|
||||
if 0 in chunk:
|
||||
break
|
||||
s = bytes(raw).split(b"\0")[0]
|
||||
if len(s) >= 2 and good_string(s) >= min_chars:
|
||||
print(f" [{i:#x}] {s.decode('cp932')!r}")
|
||||
found += 1
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
scan("SC0030.BIN")
|
||||
scan("MENU.BIN", limit=12)
|
||||
64
tools/probe_xref.py
Normal file
64
tools/probe_xref.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Find how instructions reference string offsets, and inspect 0x71 operands
|
||||
in context. Uses T3 jump-target values as confirmed instruction starts.
|
||||
"""
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "extracted" / "DATA1"
|
||||
|
||||
def parse(name):
|
||||
data = (ROOT / name).read_bytes()
|
||||
f = struct.unpack_from("<13I", data, 8)
|
||||
body = data[0x3C:]
|
||||
n = len(body) // 4
|
||||
dws = struct.unpack_from(f"<{n}I", body, 0)
|
||||
return f, dws, body
|
||||
|
||||
def decode_str(body, i):
|
||||
raw = bytearray()
|
||||
n = len(body) // 4
|
||||
for j in range(i, min(i + 60, n)):
|
||||
chunk = bytes(b ^ 0xFF for b in body[j*4:(j+1)*4])
|
||||
raw += chunk
|
||||
if 0 in chunk:
|
||||
break
|
||||
try:
|
||||
return bytes(raw).split(b"\0")[0].decode("cp932")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
def xref(name, targets):
|
||||
f, dws, body = parse(name)
|
||||
print(f"\n=== {name}: xrefs to {[hex(t) for t in targets]} ===")
|
||||
for t in targets:
|
||||
hits = [i for i, v in enumerate(dws[:f[8]]) if v == t]
|
||||
for i in hits:
|
||||
lo = max(0, i - 6)
|
||||
ctx = " ".join(f"{v:x}" for v in dws[lo:i + 3])
|
||||
print(f" {t:#x} referenced at [{i:#x}]: ...{ctx}...")
|
||||
|
||||
def sample_71(name, k=6):
|
||||
f, dws, body = parse(name)
|
||||
print(f"\n=== {name}: T1 (0x71) operands in context ===")
|
||||
off, cnt = f[8], f[7]
|
||||
for e in list(dws[off:off + cnt])[:k]:
|
||||
lo = max(0, e - 8)
|
||||
ctx = " ".join(f"{v:x}" for v in dws[lo:e + 4])
|
||||
print(f" target [{e:#x}]: ...{ctx}...")
|
||||
|
||||
def first_instrs(name, count=20):
|
||||
"""Dump first N dwords raw, plus known instruction starts from T3 values."""
|
||||
f, dws, body = parse(name)
|
||||
starts = sorted(set(dws[f[12]:f[12] + f[11]]))
|
||||
print(f"\n=== {name}: first dwords ===")
|
||||
print(" " + " ".join(f"{v:x}" for v in dws[:count]))
|
||||
print(f" T3-confirmed instruction starts (first 12): {[hex(s) for s in starts[:12]]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
xref("MENU.BIN", [0x303, 0x30B, 0x30E])
|
||||
xref("SC0030.BIN", [0xEF80, 0xEF89, 0xEF91])
|
||||
sample_71("SC0030.BIN")
|
||||
sample_71("MENU.BIN", k=2)
|
||||
first_instrs("MENU.BIN")
|
||||
first_instrs("SC0030.BIN")
|
||||
521
tools/sys4load.py
Normal file
521
tools/sys4load.py
Normal file
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sys4load — loader / disassembler-ish dumper for Eushully SYS4 `.BIN` scripts.
|
||||
|
||||
Parses the confirmed container format (see ../sys4-format-notes.md):
|
||||
* 60-byte header: magic "SYS4422 " + 13 little-endian u32 fields
|
||||
* body (dword stream) = CODE + three 1-dword pointer tables + inline strings
|
||||
* strings: XOR-0xFF cp932, NUL-terminated, referenced by a `0x02 <dword-off>` pair
|
||||
|
||||
The container format is byte-verified across all 481 DATA1 scripts. Opcodes are now
|
||||
DECODED using the AGE opcode table (age_opcodes.py, transcribed from Kelebek1's
|
||||
decompiler and validated 476/476 clean on Himegari): the code section is a flat stream
|
||||
of `<opcode:u32> + argc*(<argtype:u32><value:u32>)` instructions, length 1+2*argc dwords.
|
||||
Inline strings live after the code inside [0,F8), so decoding stops at the first string
|
||||
(type-2) or array (op 0x64) operand offset.
|
||||
|
||||
Usage:
|
||||
sys4load.py <file.BIN> full disassembly listing
|
||||
sys4load.py <file.BIN> --summary header + section sizes + table/string counts
|
||||
sys4load.py <file.BIN> --strings decoded string pool only
|
||||
sys4load.py <file.BIN> --json machine-readable structure (no code dump)
|
||||
sys4load.py <dir> --validate re-check format invariants across a folder
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from age_opcodes import (OPCODES, ARG_TYPES, CONTROL_FLOW, ARRAY_OPCODE,
|
||||
is_label_argument)
|
||||
except ImportError: # allow import from another cwd
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from age_opcodes import (OPCODES, ARG_TYPES, CONTROL_FLOW, ARRAY_OPCODE,
|
||||
is_label_argument)
|
||||
|
||||
# Himegari inference layer (optional): improves labels for unnamed opcodes. Keeps the
|
||||
# verbatim Kelebek table (age_opcodes) pristine; see age_opcodes_himegari.py.
|
||||
try:
|
||||
from age_opcodes_himegari import INFERRED
|
||||
except ImportError:
|
||||
INFERRED = {}
|
||||
|
||||
# Global-variable labels (optional): annotate global operands with the partial global-var
|
||||
# map (build/global-var-map.json, produced by tools/global_map.py). High/medium confidence
|
||||
# only — the low-confidence tail (~12k sparse guesses) is left out to keep listings readable.
|
||||
GLOBAL_ATYPES = {3, 4, 5, 6, 8} # global-int/float/string/ptr/string-ptr
|
||||
|
||||
|
||||
def _short_global_label(lbl: str) -> str:
|
||||
if lbl.startswith("record-table[stride "):
|
||||
return "rec[s" + lbl[len("record-table[stride "):-1] + "]"
|
||||
if lbl.startswith("string-table (written by "):
|
||||
return "str<" + lbl[len("string-table (written by "):-1] + ">"
|
||||
return lbl
|
||||
|
||||
|
||||
def _load_global_labels() -> dict:
|
||||
try:
|
||||
p = Path(__file__).resolve().parent.parent / "build" / "global-var-map.json"
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
out = {}
|
||||
for addr_s, e in data.get("globals", {}).items():
|
||||
lbl, conf = e.get("label"), e.get("confidence")
|
||||
if lbl and conf in ("high", "med"):
|
||||
out[int(addr_s, 16)] = _short_global_label(lbl)
|
||||
return out
|
||||
|
||||
|
||||
GLOBAL_LABELS = _load_global_labels()
|
||||
|
||||
|
||||
def display_label(op: int) -> str:
|
||||
"""Rendered mnemonic: Kelebek name if it has one, else the inferred name, else u00…."""
|
||||
lbl = OPCODES.get(op, (f"?{op:x}", 0))[0]
|
||||
is_unnamed = (lbl.startswith(("u00", "dev_ukn")) or lbl.lower() == f"{op:x}")
|
||||
if is_unnamed and op in INFERRED:
|
||||
return INFERRED[op]["name"]
|
||||
return lbl
|
||||
|
||||
MAGIC = b"SYS4422 " # canonical; also seen: SYS4424 (patch scripts). Both 0x3C headers.
|
||||
MAGIC_PREFIX = b"SYS4" # accept the whole SYS4 script family (SYS4422 / SYS4424 / ...)
|
||||
HEADER_SIZE = 0x3C # 8 magic + 13*4
|
||||
NUM_FIELDS = 13
|
||||
BODY_OFF = HEADER_SIZE
|
||||
|
||||
# Tag dword found at the target of each pointer table (100% pure across corpus).
|
||||
TABLE_TAGS = {"T1": 0x71, "T2": 0x03, "T3": 0x8F}
|
||||
STRING_REF_TAG = 0x02 # dword preceding an inline string offset
|
||||
|
||||
|
||||
class Sys4Error(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instruction:
|
||||
offset: int # dword index of the opcode within the body
|
||||
opcode: int
|
||||
label: str | None # mnemonic from the opcode table, or None if unknown
|
||||
args: list # list of (type, value) operand pairs
|
||||
unknown: bool = False # opcode not in the table (decode desynced/stopped)
|
||||
truncated: bool = False # not enough dwords left for the declared arg count
|
||||
|
||||
@property
|
||||
def size(self): # length in dwords
|
||||
return 1 + 2 * len(self.args)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sys4Script:
|
||||
path: Path
|
||||
fields: tuple # 13 header u32s (F0..F12)
|
||||
dwords: tuple # body as tuple[int], length = nbody
|
||||
magic: str = "SYS4422 " # actual 8-byte magic (SYS4422 / SYS4424 / ...)
|
||||
strings: dict = field(default_factory=dict) # start_dword -> (text, ndwords)
|
||||
string_refs: dict = field(default_factory=dict) # value-operand dword index -> string start
|
||||
instructions: list = field(default_factory=list) # decoded Instruction list
|
||||
code_end: int = 0 # dword where code stops and inline strings begin (<= code_len)
|
||||
|
||||
# ---- section geometry (all in dword units, relative to body start) ----
|
||||
@property
|
||||
def nbody(self):
|
||||
return len(self.dwords)
|
||||
|
||||
@property
|
||||
def code_len(self):
|
||||
return self.fields[8] # F8
|
||||
|
||||
@property
|
||||
def t1(self):
|
||||
return (self.fields[7], self.fields[8], self.fields[10]) # count, off, end
|
||||
|
||||
@property
|
||||
def t2(self):
|
||||
return (self.fields[9], self.fields[10], self.fields[12])
|
||||
|
||||
@property
|
||||
def t3(self):
|
||||
return (self.fields[11], self.fields[12], self.nbody)
|
||||
|
||||
def table_entries(self, which):
|
||||
count, off, _ = getattr(self, which.lower())
|
||||
return self.dwords[off:off + count]
|
||||
|
||||
# ---- byte offsets for reporting ----
|
||||
@staticmethod
|
||||
def dword_to_file_off(idx):
|
||||
return BODY_OFF + idx * 4
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _decode_string(dwords, start, limit=4096):
|
||||
"""Decode a XOR-0xFF cp932 string beginning at dword `start`.
|
||||
|
||||
Returns (text, ndwords) or (None, 0) if it isn't a clean string.
|
||||
"""
|
||||
raw = bytearray()
|
||||
n = len(dwords)
|
||||
end = min(start + limit, n)
|
||||
for j in range(start, end):
|
||||
raw += struct.pack("<I", dwords[j] ^ 0xFFFFFFFF)
|
||||
if 0 in raw[-4:]:
|
||||
break
|
||||
else:
|
||||
return None, 0
|
||||
s = bytes(raw).split(b"\0")[0]
|
||||
if len(s) < 1:
|
||||
return "", 1
|
||||
# strict cp932 shape: ascii-printable or valid 2-byte lead+trail
|
||||
i, chars = 0, 0
|
||||
while i < len(s):
|
||||
b = s[i]
|
||||
if 0x20 <= b <= 0x7E:
|
||||
i += 1
|
||||
chars += 1
|
||||
elif 0x81 <= b <= 0x9F or 0xE0 <= b <= 0xEA:
|
||||
if i + 1 < len(s) and 0x40 <= s[i + 1] <= 0xFC and s[i + 1] != 0x7F:
|
||||
i += 2
|
||||
chars += 1
|
||||
else:
|
||||
return None, 0
|
||||
else:
|
||||
return None, 0
|
||||
if chars < 1:
|
||||
return None, 0
|
||||
try:
|
||||
text = s.decode("cp932")
|
||||
except UnicodeDecodeError:
|
||||
return None, 0
|
||||
ndwords = (len(bytes(raw).split(b"\0")[0]) // 4) + 1 # include the NUL dword
|
||||
return text, ndwords
|
||||
|
||||
|
||||
def load(path) -> Sys4Script:
|
||||
path = Path(path)
|
||||
data = path.read_bytes()
|
||||
if len(data) < HEADER_SIZE:
|
||||
raise Sys4Error(f"{path.name}: too small ({len(data)} bytes)")
|
||||
if data[:4] != MAGIC_PREFIX:
|
||||
raise Sys4Error(f"{path.name}: bad magic {data[:8]!r}")
|
||||
if len(data) % 4:
|
||||
raise Sys4Error(f"{path.name}: length {len(data)} not dword-aligned")
|
||||
fields = struct.unpack_from(f"<{NUM_FIELDS}I", data, 8)
|
||||
nbody = (len(data) - BODY_OFF) // 4
|
||||
dwords = struct.unpack_from(f"<{nbody}I", data, BODY_OFF)
|
||||
|
||||
scr = Sys4Script(path=path, fields=fields, dwords=dwords,
|
||||
magic=data[:8].decode("ascii", "replace"))
|
||||
_check_invariants(scr)
|
||||
decode_code(scr)
|
||||
return scr
|
||||
|
||||
|
||||
def decode_code(scr: Sys4Script):
|
||||
"""Walk the code section into instructions, resolving inline strings.
|
||||
|
||||
Model (age_opcodes.py): instruction = <opcode> + argc*(<type><value>), length
|
||||
1+2*argc dwords. Inline strings sit after the code inside [0,F8); a type-2 (string)
|
||||
or op-0x64 array operand offset marks where code ends, so we lower `code_end` to the
|
||||
smallest such offset seen and stop there. Populates scr.instructions / .strings /
|
||||
.string_refs / .code_end.
|
||||
"""
|
||||
dw = scr.dwords
|
||||
nbody = scr.nbody
|
||||
code_end = scr.code_len # F8; shrinks to first inline-string/array offset
|
||||
instrs, strings, refs = [], {}, {}
|
||||
i = 0
|
||||
while i < code_end:
|
||||
op = dw[i]
|
||||
info = OPCODES.get(op)
|
||||
if info is None:
|
||||
instrs.append(Instruction(i, op, None, [], unknown=True))
|
||||
break
|
||||
label, argc = info
|
||||
base = i + 1
|
||||
if base + 2 * argc > code_end:
|
||||
instrs.append(Instruction(i, op, label, [], truncated=True))
|
||||
break
|
||||
args = []
|
||||
for a in range(argc):
|
||||
atype = dw[base + 2 * a]
|
||||
aval = dw[base + 2 * a + 1]
|
||||
args.append((atype, aval))
|
||||
if atype == 2 and 0 <= aval < nbody: # inline string operand
|
||||
code_end = min(code_end, aval)
|
||||
if aval not in strings:
|
||||
text, nd = _decode_string(dw, aval)
|
||||
if text is not None:
|
||||
strings[aval] = (text, nd)
|
||||
refs[base + 2 * a + 1] = aval
|
||||
elif op == ARRAY_OPCODE and a == 1 and 0 <= aval < nbody: # footer array ref
|
||||
code_end = min(code_end, aval)
|
||||
instrs.append(Instruction(i, op, label, args))
|
||||
i = base + 2 * argc
|
||||
|
||||
scr.instructions = instrs
|
||||
scr.strings = strings
|
||||
scr.string_refs = refs
|
||||
scr.code_end = code_end
|
||||
return scr
|
||||
|
||||
|
||||
def _check_invariants(scr: Sys4Script):
|
||||
f = scr.fields
|
||||
nbody = scr.nbody
|
||||
F7, F8, F9, F10, F11, F12 = f[7], f[8], f[9], f[10], f[11], f[12]
|
||||
problems = []
|
||||
if f[6] != 0x1C:
|
||||
problems.append(f"F6={f[6]:#x} != 0x1C")
|
||||
if not (0 <= F8 <= F10 <= F12 <= nbody):
|
||||
problems.append(f"section ordering 0<=F8({F8})<=F10({F10})<=F12({F12})<=nbody({nbody})")
|
||||
else:
|
||||
if F10 - F8 != F7:
|
||||
problems.append(f"T1 size {(F10 - F8)} != count {F7}")
|
||||
if F12 - F10 != F9:
|
||||
problems.append(f"T2 size {(F12 - F10)} != count {F9}")
|
||||
if nbody - F12 != F11:
|
||||
problems.append(f"T3 size {(nbody - F12)} != count {F11}")
|
||||
if problems:
|
||||
raise Sys4Error(f"{scr.path.name}: " + "; ".join(problems))
|
||||
|
||||
|
||||
def check_table_tags(scr: Sys4Script):
|
||||
"""Return dict which -> (ok_count, total) for target-tag purity."""
|
||||
out = {}
|
||||
for which, tag in TABLE_TAGS.items():
|
||||
entries = scr.table_entries(which)
|
||||
ok = sum(1 for e in entries if 0 <= e < scr.nbody and scr.dwords[e] == tag)
|
||||
out[which] = (ok, len(entries))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# rendering
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _fmt_dwords(dwords, lo, hi, per_line=8):
|
||||
out = []
|
||||
for i in range(lo, hi, per_line):
|
||||
chunk = dwords[i:min(i + per_line, hi)]
|
||||
out.append(" " + " ".join(f"{v:08x}" for v in chunk))
|
||||
return out
|
||||
|
||||
|
||||
def decode_stats(scr: Sys4Script):
|
||||
"""(n_instructions, n_unknown, n_truncated, clean) for the decoded code."""
|
||||
n = len(scr.instructions)
|
||||
unk = sum(1 for ins in scr.instructions if ins.unknown)
|
||||
trunc = sum(1 for ins in scr.instructions if ins.truncated)
|
||||
clean = unk == 0 and trunc == 0 and scr.code_end == (
|
||||
scr.instructions[-1].offset + scr.instructions[-1].size if scr.instructions else 0)
|
||||
return n, unk, trunc, clean
|
||||
|
||||
|
||||
def render_summary(scr: Sys4Script) -> str:
|
||||
f = scr.fields
|
||||
tags = check_table_tags(scr)
|
||||
n, unk, trunc, clean = decode_stats(scr)
|
||||
lines = [
|
||||
f"file {scr.path.name}",
|
||||
f"body {scr.nbody} dwords ({scr.nbody * 4} bytes)",
|
||||
f"header F0={f[0]:#x} F1={f[1]} F2={f[2]:#x} F3={f[3]:#x} "
|
||||
f"F4={f[4]} F5={f[5]:#x} F6={f[6]:#x} (F0-F5 = local var counts)",
|
||||
f"code [0x00000 .. 0x{scr.code_end:05x}) {scr.code_end} dwords, "
|
||||
f"{n} instructions"
|
||||
+ (f", strings 0x{scr.code_end:05x}..0x{scr.code_len:05x}" if scr.code_end < scr.code_len else ""),
|
||||
f"decode {'CLEAN' if clean else 'INCOMPLETE'}"
|
||||
+ (f" ({unk} unknown, {trunc} truncated)" if (unk or trunc) else ""),
|
||||
f"table T1 off 0x{f[8]:05x} count {f[7]:<6} tag 0x71 "
|
||||
f"purity {tags['T1'][0]}/{tags['T1'][1]}",
|
||||
f"table T2 off 0x{f[10]:05x} count {f[9]:<6} tag 0x03 "
|
||||
f"purity {tags['T2'][0]}/{tags['T2'][1]}",
|
||||
f"table T3 off 0x{f[12]:05x} count {f[11]:<6} tag 0x8f "
|
||||
f"purity {tags['T3'][0]}/{tags['T3'][1]}",
|
||||
f"strings {len(scr.strings)} inline, {len(scr.string_refs)} references",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_strings(scr: Sys4Script) -> str:
|
||||
out = []
|
||||
for off in sorted(scr.strings):
|
||||
text, nd = scr.strings[off]
|
||||
fo = scr.dword_to_file_off(off)
|
||||
out.append(f" [0x{off:05x} @file 0x{fo:06x}] ({nd}dw) {text!r}")
|
||||
return "\n".join(out) if out else " (no inline strings)"
|
||||
|
||||
|
||||
def _fmt_operand(op: int, arg_index: int, atype: int, aval: int, strings: dict) -> str:
|
||||
"""Render one (type, value) operand the way the disassembler labels them."""
|
||||
if atype == 2: # inline string
|
||||
text = strings.get(aval, ("?",))[0]
|
||||
return f'"{text}"'
|
||||
if is_label_argument(op, arg_index, aval): # code-offset jump/call target
|
||||
return f"label_{aval:x}"
|
||||
tlabel = ARG_TYPES.get(atype)
|
||||
if atype == 0 or tlabel is None: # immediate / unknown-tag: raw value
|
||||
if atype == 0:
|
||||
return f"{aval:#x}"
|
||||
return f"<t{atype:#x} {aval:#x}>"
|
||||
if tlabel == "float":
|
||||
return f"(float {aval:#x})"
|
||||
ann = ""
|
||||
if atype in GLOBAL_ATYPES and aval in GLOBAL_LABELS:
|
||||
ann = f" ={GLOBAL_LABELS[aval]}" # inferred global-var-map alias
|
||||
return f"({tlabel} {aval:#x}{ann})" # e.g. (global-int 17a =skill-name-table)
|
||||
|
||||
|
||||
def render_listing(scr: Sys4Script) -> str:
|
||||
"""Disassembly of the code section using the AGE opcode table."""
|
||||
out = [render_summary(scr), "", "; ---- CODE ----"]
|
||||
|
||||
# collect jump/call label targets so we can print label_XXXX: anchors
|
||||
label_targets = set()
|
||||
for ins in scr.instructions:
|
||||
for x, (atype, aval) in enumerate(ins.args):
|
||||
if is_label_argument(ins.opcode, x, aval):
|
||||
label_targets.add(aval)
|
||||
|
||||
for ins in scr.instructions:
|
||||
if ins.offset in label_targets:
|
||||
out.append(f"label_{ins.offset:x}:")
|
||||
if ins.unknown:
|
||||
out.append(f" 0x{ins.offset:05x}: ??? 0x{ins.opcode:x} ; unknown opcode — decode stopped")
|
||||
continue
|
||||
if ins.truncated:
|
||||
out.append(f" 0x{ins.offset:05x}: {ins.label} <truncated>")
|
||||
continue
|
||||
ops = " ".join(_fmt_operand(ins.opcode, x, t, v, scr.strings)
|
||||
for x, (t, v) in enumerate(ins.args))
|
||||
mnem = display_label(ins.opcode) # prefers Kelebek name, else inferred, else u00…
|
||||
# annotate unnamed/inferred ops with their raw value for grep-ability
|
||||
kelebek = ins.label
|
||||
unnamed = kelebek.startswith(("u00", "dev_ukn")) or kelebek[:1].isdigit()
|
||||
raw = f" ; op 0x{ins.opcode:x}" + (" inferred" if unnamed and ins.opcode in INFERRED else "") \
|
||||
if unnamed else ""
|
||||
out.append(f" 0x{ins.offset:05x}: {mnem}{(' ' + ops) if ops else ''}{raw}")
|
||||
|
||||
out.append("")
|
||||
out.append("; ---- TABLES ----")
|
||||
for which in ("T1", "T2", "T3"):
|
||||
count, off, end = getattr(scr, which.lower())
|
||||
entries = scr.table_entries(which)
|
||||
preview = " ".join(f"{e:x}" for e in entries[:16])
|
||||
more = " ..." if count > 16 else ""
|
||||
out.append(f"{which} (count {count}, off 0x{off:05x}): {preview}{more}")
|
||||
out.append("")
|
||||
out.append("; ---- STRINGS ----")
|
||||
out.append(render_strings(scr))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def to_dict(scr: Sys4Script, with_code=False) -> dict:
|
||||
f = scr.fields
|
||||
n, unk, trunc, clean = decode_stats(scr)
|
||||
d = {
|
||||
"file": scr.path.name,
|
||||
"magic": scr.magic,
|
||||
"fields": {f"F{i}": f[i] for i in range(NUM_FIELDS)},
|
||||
"body_dwords": scr.nbody,
|
||||
"sections": {
|
||||
"code": {"off": 0, "len": scr.code_len, "code_end": scr.code_end},
|
||||
"T1": {"count": f[7], "off": f[8], "tag": 0x71},
|
||||
"T2": {"count": f[9], "off": f[10], "tag": 0x03},
|
||||
"T3": {"count": f[11], "off": f[12], "tag": 0x8F},
|
||||
},
|
||||
"decode": {"instructions": n, "unknown": unk, "truncated": trunc, "clean": clean},
|
||||
"table_tag_purity": {k: v for k, v in check_table_tags(scr).items()},
|
||||
"strings": {f"0x{off:x}": txt for off, (txt, _) in sorted(scr.strings.items())},
|
||||
}
|
||||
if with_code:
|
||||
d["code"] = [
|
||||
{"off": ins.offset, "op": f"0x{ins.opcode:x}", "label": ins.label,
|
||||
"args": [[f"0x{t:x}", f"0x{v:x}"] for t, v in ins.args]}
|
||||
for ins in scr.instructions
|
||||
]
|
||||
return d
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validation pass over a directory
|
||||
# --------------------------------------------------------------------------- #
|
||||
def validate_dir(root: Path) -> int:
|
||||
files = sorted(root.glob("*.BIN"))
|
||||
if not files:
|
||||
print(f"no .BIN files in {root}")
|
||||
return 1
|
||||
ok, bad = 0, 0
|
||||
tag_fail = 0
|
||||
decode_clean = 0
|
||||
decode_dirty = []
|
||||
for p in files:
|
||||
try:
|
||||
scr = load(p)
|
||||
except Sys4Error as e:
|
||||
print(f" FAIL {e}")
|
||||
bad += 1
|
||||
continue
|
||||
purity = check_table_tags(scr)
|
||||
impure = [k for k, (o, t) in purity.items() if o != t]
|
||||
if impure:
|
||||
tag_fail += 1
|
||||
print(f" TAG {p.name}: impure tables {impure} {purity}")
|
||||
_, unk, trunc, clean = decode_stats(scr)
|
||||
if clean:
|
||||
decode_clean += 1
|
||||
else:
|
||||
decode_dirty.append((p.name, unk, trunc))
|
||||
ok += 1
|
||||
print(f"\n{len(files)} files: {ok} parsed clean, {bad} header/section failures, "
|
||||
f"{tag_fail} with impure table tags")
|
||||
print(f"opcode decode: {decode_clean}/{ok} fully clean (0 unknown/truncated)")
|
||||
for name, unk, trunc in decode_dirty[:20]:
|
||||
print(f" DECODE {name}: {unk} unknown, {trunc} truncated")
|
||||
if len(decode_dirty) > 20:
|
||||
print(f" ... and {len(decode_dirty) - 20} more")
|
||||
return 0 if bad == 0 and tag_fail == 0 else 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="SYS4 .BIN loader / dumper")
|
||||
ap.add_argument("target", help="a .BIN file, or a directory with --validate")
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--summary", action="store_true", help="header + section sizes only")
|
||||
g.add_argument("--strings", action="store_true", help="decoded string pool only")
|
||||
g.add_argument("--json", action="store_true", help="machine-readable structure")
|
||||
g.add_argument("--validate", action="store_true", help="check invariants over a folder")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
target = Path(args.target)
|
||||
if args.validate or target.is_dir():
|
||||
return validate_dir(target)
|
||||
|
||||
try:
|
||||
scr = load(target)
|
||||
except Sys4Error as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.summary:
|
||||
print(render_summary(scr))
|
||||
elif args.strings:
|
||||
print(render_strings(scr))
|
||||
elif args.json:
|
||||
print(json.dumps(to_dict(scr), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(render_listing(scr))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
58
tools/validate_opcode_table.py
Normal file
58
tools/validate_opcode_table.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Definitive test: replicate Kelebek's data_array_end shrinking (stop code at first
|
||||
string/array offset), then measure clean decode rate over all Himegari scripts."""
|
||||
import os, re, sys, collections
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
CPP=paths.KELEBEK_CPP.read_text(encoding="utf-8")
|
||||
TABLE={}; LABEL={}
|
||||
for m in re.finditer(r'\{\s*(0x[0-9A-Fa-f]+)\s*,\s*"([^"]*)"\s*,\s*(0x[0-9A-Fa-f]+)\s*\}', CPP):
|
||||
TABLE[int(m.group(1),16)]=int(m.group(3),16); LABEL[int(m.group(1),16)]=m.group(2)
|
||||
|
||||
files=paths.scripts()
|
||||
|
||||
clean=dirty=0; parsefail=0
|
||||
still_unknown=collections.Counter(); examples={}
|
||||
instr_total=0
|
||||
for name,p in sorted(files.items()):
|
||||
try: scr=sys4load.load(p)
|
||||
except Exception: parsefail+=1; continue
|
||||
dw=scr.dwords; cl=scr.code_len
|
||||
end=cl # data_array_end, starts at F8, shrinks to first string/array off
|
||||
i=0; ok=True; reason=""
|
||||
n_instr=0
|
||||
while i<end:
|
||||
op=dw[i]
|
||||
if op not in TABLE:
|
||||
ok=False; reason=f"unknown 0x{op:x}@{i}"
|
||||
if op<0x1000: still_unknown[op]+=1; examples.setdefault(op,name)
|
||||
break
|
||||
argc=TABLE[op]
|
||||
base=i+1
|
||||
if base+2*argc>end:
|
||||
ok=False; reason=f"overrun 0x{op:x}@{i}"; break
|
||||
for a in range(argc):
|
||||
atype=dw[base+2*a]; aval=dw[base+2*a+1]
|
||||
if atype==2 and 0<=aval<end: # inline string -> shrink code end
|
||||
end=min(end,aval)
|
||||
if op==0x64 and a==1 and 0<=aval<end: # copy-local-array footer ref
|
||||
end=min(end,aval)
|
||||
i=base+2*argc; n_instr+=1
|
||||
if ok and i==end:
|
||||
clean+=1; instr_total+=n_instr
|
||||
else:
|
||||
dirty+=1
|
||||
if len(examples)<20 and reason: pass
|
||||
|
||||
print(f"scripts: {len(files)} parsefail(container): {parsefail}")
|
||||
print(f"CLEAN decode (Kelebek table + string-pool boundary): {clean}")
|
||||
print(f"DIRTY: {dirty}")
|
||||
print(f"total instructions decoded in clean files: {instr_total}")
|
||||
print(f"\nremaining small unknown opcodes (genuine gaps):")
|
||||
for op,c in still_unknown.most_common():
|
||||
print(f" 0x{op:x} files={c} e.g. {examples.get(op)} known={op in TABLE}")
|
||||
if not still_unknown:
|
||||
print(" (none)")
|
||||
80
tools/validate_opcode_table_naive.py
Normal file
80
tools/validate_opcode_table_naive.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate Kelebek1's AGE opcode table against Himegari SYS4 scripts.
|
||||
|
||||
Model (from Kelebek1 disassembler.cpp + age-shared.cpp):
|
||||
code stream = sequence of instructions.
|
||||
each instruction = <opcode:u32> then argument_count * <arg>, where each arg = <type:u32><value:u32>.
|
||||
=> instruction length in dwords = 1 + 2*argument_count (uniform; type-2/0x64 args seek elsewhere, don't consume inline)
|
||||
arg type 2 = inline string (value = dword offset into body). types: 0 imm,1 float,3 g-int,9 l-int, etc.
|
||||
A clean decode consumes exactly code_len dwords with no unknown opcode and no arg overrun.
|
||||
"""
|
||||
import os, re, sys, collections
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import paths
|
||||
import sys4load
|
||||
|
||||
CPP = paths.KELEBEK_CPP.read_text(encoding="utf-8")
|
||||
# parse {0x1F4, "label", 0x0},
|
||||
TABLE = {}
|
||||
LABEL = {}
|
||||
for m in re.finditer(r'\{\s*(0x[0-9A-Fa-f]+)\s*,\s*"([^"]*)"\s*,\s*(0x[0-9A-Fa-f]+)\s*\}', CPP):
|
||||
op = int(m.group(1), 16); lbl = m.group(2); argc = int(m.group(3), 16)
|
||||
TABLE[op] = argc; LABEL[op] = lbl
|
||||
print(f"parsed {len(TABLE)} opcode defs from Kelebek1 table (max op 0x{max(TABLE):x})")
|
||||
|
||||
files = paths.scripts()
|
||||
|
||||
clean = dirty = parsefail = 0
|
||||
unknown_ops = collections.Counter()
|
||||
fail_examples = []
|
||||
str_ok = str_bad = 0
|
||||
opcode_use = collections.Counter()
|
||||
|
||||
for name, p in sorted(files.items()):
|
||||
try:
|
||||
scr = sys4load.load(p)
|
||||
except Exception:
|
||||
parsefail += 1; continue
|
||||
dw = scr.dwords; cl = scr.code_len
|
||||
i = 0; ok = True; reason = ""
|
||||
while i < cl:
|
||||
op = dw[i]
|
||||
if op not in TABLE:
|
||||
unknown_ops[op] += 1; ok = False; reason = f"unknown op 0x{op:x} @{i}"; break
|
||||
opcode_use[op] += 1
|
||||
argc = TABLE[op]
|
||||
# check each arg's type; resolve strings
|
||||
base = i + 1
|
||||
if base + 2*argc > cl:
|
||||
ok = False; reason = f"arg overrun op 0x{op:x} @{i} needs {argc} args"; break
|
||||
for a in range(argc):
|
||||
atype = dw[base + 2*a]; aval = dw[base + 2*a + 1]
|
||||
if atype == 2: # inline string
|
||||
if 0 <= aval < scr.nbody:
|
||||
txt, nd = sys4load._decode_string(dw, aval)
|
||||
if txt is None: str_bad += 1
|
||||
else: str_ok += 1
|
||||
else:
|
||||
str_bad += 1
|
||||
i = base + 2*argc
|
||||
if ok and i == cl:
|
||||
clean += 1
|
||||
else:
|
||||
dirty += 1
|
||||
if len(fail_examples) < 15:
|
||||
fail_examples.append(f" {name}: {reason} (stopped @{i}/{cl})")
|
||||
|
||||
print(f"\n== decode result over {len(files)} scripts ==")
|
||||
print(f" clean (fully consumed, all opcodes known): {clean}")
|
||||
print(f" dirty: {dirty}")
|
||||
print(f" parse-fail (container): {parsefail}")
|
||||
print(f" string args resolved ok / bad: {str_ok} / {str_bad}")
|
||||
print(f"\ntop unknown opcodes (op: files affected):")
|
||||
for op, c in unknown_ops.most_common(25):
|
||||
print(f" 0x{op:x}: {c}")
|
||||
print(f"\nsample dirty files:")
|
||||
print("\n".join(fail_examples))
|
||||
print(f"\ntop 25 opcodes actually used in Himegari (op label count):")
|
||||
for op, c in opcode_use.most_common(25):
|
||||
print(f" 0x{op:<4x} {LABEL.get(op,'?'):22} {c}")
|
||||
425
tools/vm0.py
Normal file
425
tools/vm0.py
Normal file
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
"""vm0 — headless AGE bytecode interpreter (Phase A0 prototype).
|
||||
|
||||
Executes one script's bytecode to validate the *execution model* before any C#/Godot work.
|
||||
Reuses tools/sys4load.py for all parsing/decoding. Effectful ops and call-script are STUBBED;
|
||||
show-text is captured. Named-op semantics come from Kelebek's table; the classified markers are
|
||||
treated as no-ops (this run TESTS that assumption).
|
||||
|
||||
Purpose (see docs/phase-a-slice-plan.md):
|
||||
* `--test` run the RECOVER unit test (pointer / 2D-array / loop / control-flow correctness).
|
||||
* <file> run a script; print captured show-text + an opcode-coverage/stub report.
|
||||
|
||||
Memory model:
|
||||
* one flat GLOBAL bank G (dict addr->int); globals are raw offsets into one space.
|
||||
* per-call local frame with sparse typed banks (int/float/string/ptr).
|
||||
* a `-ptr` variable holds an ADDRESS into G. lookup-array/2d with a ptr dst stores that
|
||||
address (take-reference); reading a ptr derefs (G[addr]); writing through a ptr writes G[addr].
|
||||
This is the model RECOVER forces; the unit test is its litmus.
|
||||
* jcc(cond, tA, tB): cond truthy -> goto tA else tB; 0xFFFFFFFF = fall through (from RECOVER+SCJUMP).
|
||||
* call/ret (0x8F/0x05) are intra-script subroutine calls (shared frame); call-script (0x03) is
|
||||
the inter-script one and is stubbed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import collections
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import paths
|
||||
import sys4load
|
||||
from age_opcodes import OPCODES
|
||||
|
||||
NOJUMP = 0xFFFFFFFF
|
||||
|
||||
# operand type tags
|
||||
T_IMM, T_STR = 0x0, 0x2
|
||||
T_GINT, T_GFLOAT, T_GSTR, T_GPTR = 0x3, 0x4, 0x5, 0x6
|
||||
T_LINT, T_LFLOAT, T_LSTR, T_LPTR = 0x9, 0xA, 0xB, 0xC
|
||||
|
||||
# opcodes treated as no-ops in v1 (classified markers; see age_opcodes_himegari.py)
|
||||
# 0x71 = label-definition pseudo-op (count == T1 table size) — structural, no runtime effect.
|
||||
MARKERS = {0x71, 0x1f4, 0x1f5, 0x1d5, 0x1bc, 0x1bf, 0x21b, 0x1d2, 0x258}
|
||||
|
||||
# loop-guard: halt a run once any single show-text line has been emitted this many times.
|
||||
# State-gated scenes run with zero initial state can spin a loop that re-emits the same block
|
||||
# forever; capping re-emission terminates them cleanly and flags them LOOPED (vs. blind step limit).
|
||||
EMIT_CAP = 2
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self):
|
||||
self.i = collections.defaultdict(int) # local-int
|
||||
self.f = collections.defaultdict(int) # local-float (stored raw)
|
||||
self.s = collections.defaultdict(str) # local-string
|
||||
self.p = collections.defaultdict(int) # local-ptr (holds a G address)
|
||||
|
||||
|
||||
class VM:
|
||||
def __init__(self, scr: sys4load.Sys4Script, verbose=False, emit_cap=EMIT_CAP):
|
||||
self.scr = scr
|
||||
self.verbose = verbose
|
||||
self.code = scr.instructions
|
||||
self.by_off = {ins.offset: idx for idx, ins in enumerate(self.code)}
|
||||
self.G = collections.defaultdict(int) # global-int bank (flat address space)
|
||||
self.Gs = collections.defaultdict(str) # global-string bank
|
||||
self.fr = Frame()
|
||||
self.callstack = [] # return indices for call/ret
|
||||
self.text = [] # captured show-text as (str_offset, text)
|
||||
self.emit_seen = collections.Counter() # per-offset emit count (loop-guard)
|
||||
self.emit_cap = emit_cap
|
||||
self.halt_reason = None # 'exit' | 'LOOP:...' | 'STEP-LIMIT' | 'ret-underflow'
|
||||
self.log = collections.Counter() # stub/unknown opcode hits
|
||||
self.exec_count = collections.Counter() # opcode coverage
|
||||
self.steps = 0
|
||||
|
||||
# ---- operand resolution --------------------------------------------------
|
||||
def _string(self, off):
|
||||
e = self.scr.strings.get(off)
|
||||
if e is not None:
|
||||
return e[0]
|
||||
txt, _ = sys4load._decode_string(self.scr.dwords, off)
|
||||
return txt if txt is not None else ""
|
||||
|
||||
def read(self, op):
|
||||
t, v = op
|
||||
if t == T_IMM: return v
|
||||
if t == T_STR: return self._string(v)
|
||||
if t == T_GINT: return self.G[v]
|
||||
if t == T_GFLOAT: return self.G[v]
|
||||
if t == T_GSTR: return self.Gs[v]
|
||||
if t == T_GPTR: return self.G[self.G[v]]
|
||||
if t == T_LINT: return self.fr.i[v]
|
||||
if t == T_LFLOAT: return self.fr.f[v]
|
||||
if t == T_LSTR: return self.fr.s[v]
|
||||
if t == T_LPTR: return self.G[self.fr.p[v]] # deref ptr
|
||||
self.log[f"read?t{t:#x}"] += 1
|
||||
return v
|
||||
|
||||
def write(self, op, val):
|
||||
t, v = op
|
||||
if t == T_GINT or t == T_GFLOAT: self.G[v] = val
|
||||
elif t == T_GSTR: self.Gs[v] = val
|
||||
elif t == T_GPTR: self.G[self.G[v]] = val
|
||||
elif t == T_LINT: self.fr.i[v] = val
|
||||
elif t == T_LFLOAT: self.fr.f[v] = val
|
||||
elif t == T_LSTR: self.fr.s[v] = val
|
||||
elif t == T_LPTR: self.G[self.fr.p[v]] = val # write through
|
||||
else: self.log[f"write?t{t:#x}"] += 1
|
||||
|
||||
def base_addr(self, op):
|
||||
"""The base ADDRESS an operand names, for array lookups."""
|
||||
t, v = op
|
||||
if t in (T_IMM, T_GINT, T_GFLOAT, T_GSTR, T_GPTR): return v # global's own offset
|
||||
if t == T_LINT: return self.fr.i[v]
|
||||
if t == T_LPTR: return self.fr.p[v]
|
||||
return v
|
||||
|
||||
def lookup_store(self, dst, addr):
|
||||
"""lookup result: ptr dst gets the reference (address); non-ptr gets the element value."""
|
||||
t, v = dst
|
||||
if t == T_LPTR: self.fr.p[v] = addr
|
||||
elif t == T_GPTR: self.G[v] = addr
|
||||
else: self.write(dst, self.G[addr])
|
||||
|
||||
# ---- execution -----------------------------------------------------------
|
||||
def run(self, entry_off=0, max_steps=2_000_000):
|
||||
pc = self.by_off.get(entry_off, 0)
|
||||
while 0 <= pc < len(self.code):
|
||||
if self.steps >= max_steps:
|
||||
self.log["STEP-LIMIT"] += 1
|
||||
self.halt_reason = self.halt_reason or "STEP-LIMIT"
|
||||
break
|
||||
self.steps += 1
|
||||
ins = self.code[pc]
|
||||
op = ins.opcode
|
||||
self.exec_count[op] += 1
|
||||
nxt = self.step(ins, pc)
|
||||
if nxt is None: # halt
|
||||
break
|
||||
pc = nxt
|
||||
else:
|
||||
self.halt_reason = self.halt_reason or "pc-out-of-range"
|
||||
return self
|
||||
|
||||
def step(self, ins, pc):
|
||||
op, a = ins.opcode, ins.args
|
||||
lbl = OPCODES.get(op, ("?", 0))[0]
|
||||
|
||||
# arithmetic / bitwise: dst = a1 <op> a2
|
||||
alu = {"add": lambda x, y: x + y, "sub": lambda x, y: x - y,
|
||||
"mul": lambda x, y: x * y, "div": lambda x, y: (x // y if y else 0),
|
||||
"mod": lambda x, y: (x % y if y else 0), "and": lambda x, y: x & y,
|
||||
"or": lambda x, y: x | y, "sar": lambda x, y: x >> (y & 31),
|
||||
"shl": lambda x, y: x << (y & 31)}
|
||||
cmp = {"eq": lambda x, y: int(x == y), "ne": lambda x, y: int(x != y),
|
||||
"lt": lambda x, y: int(x < y), "lte": lambda x, y: int(x <= y),
|
||||
"gr": lambda x, y: int(x > y), "gre": lambda x, y: int(x >= y)}
|
||||
|
||||
if lbl in alu:
|
||||
self.write(a[0], alu[lbl](self.read(a[1]), self.read(a[2]))); return pc + 1
|
||||
if lbl in cmp:
|
||||
self.write(a[0], cmp[lbl](self.read(a[1]), self.read(a[2]))); return pc + 1
|
||||
if lbl == "mov":
|
||||
self.write(a[0], self.read(a[1])); return pc + 1
|
||||
if lbl == "set-string":
|
||||
self.write(a[0], self.read(a[1])); return pc + 1
|
||||
if lbl == "lookup-array": # dst = base[idx]
|
||||
addr = self.base_addr(a[1]) + self.read(a[2])
|
||||
self.lookup_store(a[0], addr); return pc + 1
|
||||
if lbl == "lookup-array-2d": # dst = base[i*stride + col]
|
||||
addr = self.base_addr(a[1]) + self.read(a[2]) * self.read(a[3]) + self.read(a[4])
|
||||
self.lookup_store(a[0], addr); return pc + 1
|
||||
if lbl == "bit-set":
|
||||
self.write(a[0], self.read(a[0]) | self.read(a[1])); return pc + 1
|
||||
if lbl == "bit-reset":
|
||||
self.write(a[0], self.read(a[0]) & ~self.read(a[1])); return pc + 1
|
||||
if lbl == "check-bit": # p1 = (p2 >> p3) & 1
|
||||
self.write(a[0], (self.read(a[1]) >> (self.read(a[2]) & 31)) & 1); return pc + 1
|
||||
if lbl == "copy-to-global": # best-effort: p1 = p2 (single cell)
|
||||
self.write(a[0], self.read(a[1])); return pc + 1
|
||||
|
||||
# control flow
|
||||
if lbl == "jmp":
|
||||
return self.by_off.get(a[0][1], pc + 1)
|
||||
if lbl == "call": # intra-script subroutine
|
||||
self.callstack.append(pc + 1)
|
||||
return self.by_off.get(a[0][1], pc + 1)
|
||||
if lbl == "ret":
|
||||
if self.callstack:
|
||||
return self.callstack.pop()
|
||||
self.halt_reason = "ret-underflow"
|
||||
return None
|
||||
if lbl == "jcc": # (cond, tA, tB); 0xFFFFFFFF=fallthrough
|
||||
tgt = a[1][1] if self.read(a[0]) else a[2][1]
|
||||
return pc + 1 if tgt == NOJUMP else self.by_off.get(tgt, pc + 1)
|
||||
if lbl in ("exit", "exit-script"):
|
||||
self.halt_reason = "exit"
|
||||
return None
|
||||
if lbl == "call-script": # STUB (inter-script)
|
||||
self.log[f"call-script({self.read(a[0]) if a else '?'})"] += 1; return pc + 1
|
||||
|
||||
# ADV text: capture the string operand as (offset, text); loop-guard on re-emission
|
||||
if lbl == "show-text":
|
||||
for t, v in a:
|
||||
if t == T_STR:
|
||||
self.emit_seen[v] += 1
|
||||
if self.emit_seen[v] > self.emit_cap:
|
||||
self.halt_reason = f"LOOP:line@{v:#x}×{self.emit_seen[v]}"
|
||||
return None
|
||||
self.text.append((v, self._string(v)))
|
||||
return pc + 1
|
||||
if lbl in ("end-text-line", "wait-for-input", "set-font", "comment",
|
||||
"display-furigana", "dev_ukn"):
|
||||
return pc + 1
|
||||
|
||||
if op in MARKERS: # classified no-op markers
|
||||
return pc + 1
|
||||
|
||||
# everything else (effectful draw/audio/ui/input, unnamed) -> stub + continue
|
||||
self.log[f"stub:{lbl if not lbl.startswith('u00') else hex(op)}"] += 1
|
||||
return pc + 1
|
||||
|
||||
|
||||
def run_test():
|
||||
"""RECOVER unit test — pointer/2D-array/loop/control-flow correctness."""
|
||||
scr = sys4load.load(paths.DATA1 / "RECOVER.BIN")
|
||||
vm = VM(scr)
|
||||
unit = 0
|
||||
vm.G[0x152616] = unit
|
||||
A, B = 0x4e11b, 0x4e085 # block-1 tables (stride 14 / 3)
|
||||
C, E, F, FL = 0x52383, 0x52f3b, 0x5295f, 0xaacb4 # block-2 tables (stride 30) + flags
|
||||
# block 1 source: A[unit*14 + 11..13]
|
||||
for k in range(3):
|
||||
vm.G[A + unit * 14 + (11 + k)] = 100 + k
|
||||
# block 2: slot 5 active, slot 6 zero-C (skip), slot 7 flag-off (skip)
|
||||
vm.G[C + unit * 30 + 5] = 7; vm.G[FL + 5] = 1; vm.G[E + unit * 30 + 5] = 42
|
||||
vm.G[C + unit * 30 + 6] = 0; vm.G[FL + 6] = 1; vm.G[E + unit * 30 + 6] = 99
|
||||
vm.G[C + unit * 30 + 7] = 3; vm.G[FL + 7] = 0; vm.G[E + unit * 30 + 7] = 88
|
||||
vm.run()
|
||||
|
||||
checks = [
|
||||
("block1 B[0]=A[11]", vm.G[B + unit * 3 + 0], 100),
|
||||
("block1 B[1]=A[12]", vm.G[B + unit * 3 + 1], 101),
|
||||
("block1 B[2]=A[13]", vm.G[B + unit * 3 + 2], 102),
|
||||
("block2 s5 C:=E", vm.G[C + unit * 30 + 5], 42),
|
||||
("block2 s5 F:=-1", vm.G[F + unit * 30 + 5], -1),
|
||||
("block2 s6 C skip", vm.G[C + unit * 30 + 6], 0), # C stayed 0 (guard)
|
||||
("block2 s7 C skip", vm.G[C + unit * 30 + 7], 3), # flag off -> untouched
|
||||
]
|
||||
ok = True
|
||||
for name, got, want in checks:
|
||||
status = "OK " if got == want else "FAIL"
|
||||
if got != want:
|
||||
ok = False
|
||||
print(f" [{status}] {name}: got {got}, want {want}")
|
||||
print(f" steps={vm.steps} call-script stubs={sum(v for k,v in vm.log.items() if k.startswith('call-script'))}")
|
||||
print("RECOVER unit test:", "PASS" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def run_file(path):
|
||||
scr = sys4load.load(path)
|
||||
vm = VM(scr).run()
|
||||
print(f"== {Path(path).name}: {vm.steps} steps, {len(vm.text)} show-text lines captured "
|
||||
f"(halt: {vm.halt_reason}) ==")
|
||||
for i, (off, t) in enumerate(vm.text[:20]):
|
||||
print(f" [{i}] {t}")
|
||||
if len(vm.text) > 20:
|
||||
print(f" ... (+{len(vm.text)-20} more)")
|
||||
stubs = [(k, v) for k, v in vm.log.most_common() if k.startswith("stub:")]
|
||||
if stubs:
|
||||
print(" top stubbed effectful/unknown ops:", ", ".join(f"{k[5:]}×{v}" for k, v in stubs[:10]))
|
||||
ncall = sum(v for k, v in vm.log.items() if k.startswith("call-script"))
|
||||
print(f" call-script stubs: {ncall} distinct opcodes executed: {len(vm.exec_count)}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---- dialogue oracle ---------------------------------------------------------
|
||||
import json
|
||||
import re
|
||||
|
||||
SCENE_RE = re.compile(r"^S[CP]\d{4}\.BIN$")
|
||||
|
||||
|
||||
def load_oracle(path=None):
|
||||
"""file (UPPER) -> ordered list of (str_offset, text): the static show-text lines
|
||||
extract_phase2 dumped. This is the independent oracle the VM is validated against."""
|
||||
path = path or (paths.BUILD / "text" / "dialogue.jsonl")
|
||||
by = collections.defaultdict(list)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
d = json.loads(line)
|
||||
by[d["file"].upper()].append((int(d["off"], 16), d["text"]))
|
||||
return by
|
||||
|
||||
|
||||
def subsequence_status(emitted_offs, static_offs):
|
||||
"""Classify the emitted show-text offset stream against the static ordered set.
|
||||
|
||||
Emitted may repeat offsets (loop re-emission); we validate the distinct stream in
|
||||
first-seen order as an in-order subsequence of the static lines. Returns (status,
|
||||
detail): OK | STRAY (offset never in static) | ORDER (in static but out of order)."""
|
||||
static_list = list(static_offs)
|
||||
static_set = set(static_list)
|
||||
seen, distinct = set(), []
|
||||
for o in emitted_offs:
|
||||
if o not in seen:
|
||||
seen.add(o)
|
||||
distinct.append(o)
|
||||
strays = [o for o in distinct if o not in static_set]
|
||||
if strays:
|
||||
return "STRAY", strays
|
||||
j = 0
|
||||
for o in distinct:
|
||||
while j < len(static_list) and static_list[j] != o:
|
||||
j += 1
|
||||
if j >= len(static_list):
|
||||
return "ORDER", [o]
|
||||
j += 1
|
||||
return "OK", []
|
||||
|
||||
|
||||
def verdict(status, halt, n_emit, n_static):
|
||||
"""Fold subsequence status + halt reason into one scene verdict."""
|
||||
if n_static == 0:
|
||||
return "NO-DIALOGUE" # not an ADV scene (no static show-text) — skip in scoring
|
||||
if n_emit == 0:
|
||||
return "EMPTY" # scene has dialogue but VM emitted none — investigate
|
||||
if status != "OK":
|
||||
return status # STRAY / ORDER — a real divergence
|
||||
return "CLEAN" if halt == "exit" else f"OK/{halt}" # OK subsequence; did it exit cleanly?
|
||||
|
||||
|
||||
def run_scene(name, path, oracle):
|
||||
"""Run one scene, return a result dict comparing emitted vs static show-text."""
|
||||
scr = sys4load.load(path)
|
||||
vm = VM(scr).run()
|
||||
emitted = [off for off, _ in vm.text]
|
||||
static = oracle.get(name.upper(), [])
|
||||
static_offs = [o for o, _ in static]
|
||||
status, detail = subsequence_status(emitted, static_offs)
|
||||
n_emit = len({o for o in emitted})
|
||||
return {"name": name, "vm": vm, "emitted": emitted, "static": static,
|
||||
"n_emit_distinct": n_emit, "n_static": len(static_offs),
|
||||
"status": status, "detail": detail,
|
||||
"verdict": verdict(status, vm.halt_reason, n_emit, len(static_offs))}
|
||||
|
||||
|
||||
def run_one_scene(name):
|
||||
"""Detailed single-scene oracle diff (for investigating one script)."""
|
||||
oracle = load_oracle()
|
||||
scripts = paths.scripts()
|
||||
name = name.upper()
|
||||
if not name.endswith(".BIN"):
|
||||
name += ".BIN"
|
||||
if name not in scripts:
|
||||
print(f"no such script: {name}")
|
||||
return 1
|
||||
r = run_scene(name, scripts[name], oracle)
|
||||
vm = r["vm"]
|
||||
print(f"== {name}: verdict {r['verdict']} ==")
|
||||
print(f" emitted {len(r['emitted'])} lines ({r['n_emit_distinct']} distinct), "
|
||||
f"static {r['n_static']}, steps {vm.steps}, halt {vm.halt_reason}")
|
||||
if r["status"] == "STRAY":
|
||||
stat_set = {o for o, _ in r["static"]}
|
||||
print(f" STRAY offsets (emitted, not in static): "
|
||||
f"{', '.join(hex(o) for o in r['detail'])}")
|
||||
for off, txt in vm.text:
|
||||
if off in set(r["detail"]):
|
||||
print(f" @ {off:#x}: {txt!r}")
|
||||
elif r["status"] == "ORDER":
|
||||
print(f" first out-of-order offset: {hex(r['detail'][0])}")
|
||||
return 0
|
||||
|
||||
|
||||
def run_sweep(limit=None):
|
||||
"""Run every SC####/SP#### scene against the dialogue oracle; print a coverage table."""
|
||||
oracle = load_oracle()
|
||||
scripts = paths.scripts()
|
||||
names = sorted(n for n in scripts if SCENE_RE.match(n))
|
||||
if limit:
|
||||
names = names[:limit]
|
||||
buckets = collections.Counter()
|
||||
rows = []
|
||||
for name in names:
|
||||
r = run_scene(name, scripts[name], oracle)
|
||||
buckets[r["verdict"]] += 1
|
||||
rows.append(r)
|
||||
|
||||
# non-clean scenes get listed for follow-up
|
||||
problem = [r for r in rows if r["verdict"] not in ("CLEAN", "NO-DIALOGUE")]
|
||||
if problem:
|
||||
print("non-clean scenes:")
|
||||
for r in sorted(problem, key=lambda r: r["verdict"]):
|
||||
d = ""
|
||||
if r["status"] in ("STRAY", "ORDER"):
|
||||
d = " " + " ".join(hex(o) for o in r["detail"][:4])
|
||||
print(f" {r['name']:<14} {r['verdict']:<16} "
|
||||
f"emit {r['n_emit_distinct']:>4}/{r['n_static']:<4} "
|
||||
f"steps {r['vm'].steps:>7} halt {r['vm'].halt_reason}{d}")
|
||||
print()
|
||||
scene_total = sum(v for k, v in buckets.items() if k != "NO-DIALOGUE")
|
||||
valid = sum(v for k, v in buckets.items()
|
||||
if k == "CLEAN" or k.startswith("OK/"))
|
||||
print(f"scenes with dialogue: {scene_total} (skipped {buckets['NO-DIALOGUE']} with no static show-text)")
|
||||
print("verdict breakdown:", dict(sorted(buckets.items())))
|
||||
print(f"DIALOGUE-VALID (clean in-order subsequence, no garbage): "
|
||||
f"{valid}/{scene_total} = {valid/scene_total*100:.1f}%")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
if not argv or argv[0] == "--test":
|
||||
return run_test()
|
||||
if argv[0] == "--sweep":
|
||||
return run_sweep(limit=int(argv[1]) if len(argv) > 1 else None)
|
||||
if argv[0] == "--scene":
|
||||
return run_one_scene(argv[1])
|
||||
return run_file(argv[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user