Created
February 7, 2026 03:05
-
-
Save Frityet/ce80b9c18d63a4e962dcd81d1fc101bd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env luajit | |
| local lfs = require("lfs") | |
| local function fail(message) | |
| error(message, 0) | |
| end | |
| local function printf(fmt, ...) | |
| io.write(string.format(fmt, ...)) | |
| end | |
| local function trim(s) | |
| return (s:gsub("^%s+", ""):gsub("%s+$", "")) | |
| end | |
| local function shell_quote(s) | |
| return "'" .. tostring(s):gsub("'", "'\\''") .. "'" | |
| end | |
| local function run_capture(cmd) | |
| local handle = io.popen(cmd .. " 2>&1") | |
| if not handle then | |
| return false, "failed to start command: " .. cmd, 1 | |
| end | |
| local output = handle:read("*a") or "" | |
| local ok, _, status = handle:close() | |
| if ok then | |
| return true, output, 0 | |
| end | |
| return false, output, status or 1 | |
| end | |
| local function file_exists(path) | |
| local f = io.open(path, "rb") | |
| if not f then | |
| return false | |
| end | |
| f:close() | |
| return true | |
| end | |
| local function read_file(path) | |
| local f = io.open(path, "rb") | |
| if not f then | |
| return nil | |
| end | |
| local text = f:read("*a") | |
| f:close() | |
| return text | |
| end | |
| local function write_file(path, text) | |
| local f = io.open(path, "wb") | |
| if not f then | |
| return false | |
| end | |
| f:write(text) | |
| f:close() | |
| return true | |
| end | |
| local function mkdir_p(path) | |
| if path == "" or path == "." then | |
| return true | |
| end | |
| local is_abs = path:sub(1, 1) == "/" | |
| local current = is_abs and "/" or "" | |
| for part in path:gmatch("[^/]+") do | |
| if current == "" or current == "/" then | |
| current = current .. part | |
| else | |
| current = current .. "/" .. part | |
| end | |
| local attr = lfs.attributes(current) | |
| if not attr then | |
| local ok, err = lfs.mkdir(current) | |
| if not ok then | |
| return false, err | |
| end | |
| elseif attr.mode ~= "directory" then | |
| return false, current .. " exists and is not a directory" | |
| end | |
| end | |
| return true | |
| end | |
| local function split_lines(text) | |
| local lines = {} | |
| for line in text:gmatch("([^\n]*)\n?") do | |
| if line == "" and #lines > 0 and lines[#lines] == "" and #text > 0 and text:sub(-1) ~= "\n" then | |
| break | |
| end | |
| table.insert(lines, line) | |
| end | |
| return lines | |
| end | |
| local function load_json_decoder() | |
| do | |
| local ok, mod = pcall(require, "dkjson") | |
| if ok and mod and type(mod.decode) == "function" then | |
| local null = {} | |
| local function decode(text) | |
| local obj, _, err = mod.decode(text, 1, null) | |
| if err then | |
| return nil, err, null | |
| end | |
| return obj, nil, null | |
| end | |
| return "dkjson", decode | |
| end | |
| end | |
| do | |
| local ok, mod = pcall(require, "cjson.safe") | |
| if ok and mod and type(mod.decode) == "function" then | |
| local function decode(text) | |
| local obj, err = mod.decode(text) | |
| if not obj then | |
| return nil, err or "cjson.safe decode failed", mod.null | |
| end | |
| return obj, nil, mod.null | |
| end | |
| return "cjson.safe", decode | |
| end | |
| end | |
| do | |
| local ok, mod = pcall(require, "cjson") | |
| if ok and mod and type(mod.decode) == "function" then | |
| local function decode(text) | |
| local success, obj = pcall(mod.decode, text) | |
| if not success then | |
| return nil, tostring(obj), mod.null | |
| end | |
| return obj, nil, mod.null | |
| end | |
| return "cjson", decode | |
| end | |
| end | |
| return nil, nil | |
| end | |
| local function unquote(s) | |
| local q = s:match('^"(.*)"$') | |
| if q then | |
| return q | |
| end | |
| return s | |
| end | |
| local function unescape_asm_string(s) | |
| return (s:gsub("\\\\", "\1") | |
| :gsub('\\"', '"') | |
| :gsub("\\n", "\n") | |
| :gsub("\\r", "\r") | |
| :gsub("\\t", "\t") | |
| :gsub("\\0", "\0") | |
| :gsub("\1", "\\")) | |
| end | |
| local function escape_cpp_string(s) | |
| return (s:gsub("\\", "\\\\"):gsub('"', '\\"')) | |
| end | |
| local function usage() | |
| printf("%s", [[ | |
| Usage: | |
| luajit tools/nameobj_fuzzy.lua [options] | |
| Options: | |
| --source <path> Source path to check in objdiff report. | |
| Default: src/Game/NameObj/NameObj.cpp | |
| --header <path> Header path to verify exists. | |
| Default: include/Game/NameObj/NameObj.hpp | |
| --report <path> Report JSON output path. | |
| Default: /tmp/nameobj_objdiff_report.json | |
| --generate-stubs If fuzzy < 100, generate class stubs from unresolved create*<T> funcs. | |
| --write Write generated stub header and patch source include/table. | |
| --sync-create-table Sync NameObjFactory cCreateTable from asm ground truth. | |
| --factory-source <path> Factory source to patch with stub include. | |
| Default: src/Game/NameObj/NameObjFactory.cpp | |
| --stub-header <path> Generated stub header path. | |
| Default: include/Game/NameObj/NameObjFactoryStubs.hpp | |
| --stub-dir <path> Directory for generated per-class stub headers. | |
| Default: include/Game/NameObjFactoryStubs | |
| --factory-asm <path> NameObjFactory asm path for table/size extraction. | |
| Default: build/RMGK01/asm/Game/NameObj/NameObjFactory.s | |
| --verbose Print extra command output. | |
| --help Show this help. | |
| ]]) | |
| end | |
| local cfg = { | |
| source = "src/Game/NameObj/NameObj.cpp", | |
| header = "include/Game/NameObj/NameObj.hpp", | |
| report = "/tmp/nameobj_objdiff_report.json", | |
| generate_stubs = false, | |
| write = false, | |
| sync_create_table = false, | |
| factory_source = "src/Game/NameObj/NameObjFactory.cpp", | |
| stub_header = "include/Game/NameObj/NameObjFactoryStubs.hpp", | |
| stub_dir = "include/Game/NameObjFactoryStubs", | |
| factory_asm = "build/RMGK01/asm/Game/NameObj/NameObjFactory.s", | |
| verbose = false, | |
| } | |
| local legacy_stub_header = "include/Game/NameObj/AutoNameObjFactoryStubs.hpp" | |
| local legacy_stub_dir = "include/Game/NameObj/AutoNameObjFactoryStubs" | |
| local force_stub_classes = { | |
| Flag = true, | |
| IceVolcanoUpDownPlane = true, | |
| } | |
| local morph_item_kind_by_class = { | |
| MorphItemNeoHopper = 1, | |
| MorphItemNeoBee = 2, | |
| MorphItemNeoTeresa = 3, | |
| MorphItemNeoIce = 4, | |
| MorphItemNeoFire = 5, | |
| MorphItemNeoFoo = 6, | |
| } | |
| local effect_stub_specs = { | |
| EffectObjR1000F50 = { | |
| radius = 1000.0, | |
| far = 50.0, | |
| }, | |
| EffectObjR500F50 = { | |
| radius = 500.0, | |
| far = 50.0, | |
| }, | |
| EffectObjR100F50SyncClipping = { | |
| radius = 100.0, | |
| far = 50.0, | |
| sync = true, | |
| }, | |
| EffectObj10x10x10SyncClipping = { | |
| radius = 1000.0, | |
| far = 50.0, | |
| sync = true, | |
| center_y = 580.0, | |
| }, | |
| EffectObj20x20x10SyncClipping = { | |
| radius = 1000.0, | |
| far = 50.0, | |
| sync = true, | |
| center_y = 200.0, | |
| }, | |
| EffectObj50x50x10SyncClipping = { | |
| radius = 2500.0, | |
| far = 50.0, | |
| sync = true, | |
| center_y = 200.0, | |
| }, | |
| } | |
| local special_header_paths = { | |
| MorphItemObjNeo = "include/Game/Player/MorphItemObjNeo.hpp", | |
| MorphItemNeoBee = "include/Game/Player/MorphItemNeoBee.hpp", | |
| MorphItemNeoFire = "include/Game/Player/MorphItemNeoFire.hpp", | |
| MorphItemNeoFoo = "include/Game/Player/MorphItemNeoFoo.hpp", | |
| MorphItemNeoHopper = "include/Game/Player/MorphItemNeoHopper.hpp", | |
| MorphItemNeoIce = "include/Game/Player/MorphItemNeoIce.hpp", | |
| MorphItemNeoTeresa = "include/Game/Player/MorphItemNeoTeresa.hpp", | |
| EffectObjR1000F50 = "include/Game/Effect/EffectObjR1000F50.hpp", | |
| EffectObjR500F50 = "include/Game/Effect/EffectObjR500F50.hpp", | |
| EffectObjR100F50SyncClipping = "include/Game/Effect/EffectObjR100F50SyncClipping.hpp", | |
| EffectObj10x10x10SyncClipping = "include/Game/Effect/EffectObj10x10x10SyncClipping.hpp", | |
| EffectObj20x20x10SyncClipping = "include/Game/Effect/EffectObj20x20x10SyncClipping.hpp", | |
| EffectObj50x50x10SyncClipping = "include/Game/Effect/EffectObj50x50x10SyncClipping.hpp", | |
| } | |
| local json_backend, decode_json = load_json_decoder() | |
| if not decode_json then | |
| fail("missing Lua JSON module. Install one (for example: sudo luarocks --lua-version=5.1 install dkjson)") | |
| end | |
| local function read_value(i, option) | |
| if i + 1 > #arg or arg[i + 1] == nil then | |
| fail("missing value for option: " .. option) | |
| end | |
| return arg[i + 1] | |
| end | |
| do | |
| local i = 1 | |
| while i <= #arg do | |
| local a = arg[i] | |
| if a == "--source" then | |
| cfg.source = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--header" then | |
| cfg.header = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--report" then | |
| cfg.report = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--factory-source" then | |
| cfg.factory_source = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--stub-header" then | |
| cfg.stub_header = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--stub-dir" then | |
| cfg.stub_dir = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--factory-asm" then | |
| cfg.factory_asm = read_value(i, a) | |
| i = i + 1 | |
| elseif a == "--generate-stubs" then | |
| cfg.generate_stubs = true | |
| elseif a == "--write" then | |
| cfg.write = true | |
| elseif a == "--sync-create-table" then | |
| cfg.sync_create_table = true | |
| elseif a == "--verbose" then | |
| cfg.verbose = true | |
| elseif a == "--help" or a == "-h" then | |
| usage() | |
| os.exit(0) | |
| else | |
| usage() | |
| fail("unknown argument: " .. tostring(a)) | |
| end | |
| i = i + 1 | |
| end | |
| end | |
| if not file_exists(cfg.source) then | |
| fail("missing source: " .. cfg.source) | |
| end | |
| if not file_exists(cfg.header) then | |
| fail("missing header: " .. cfg.header) | |
| end | |
| local is_nameobj_factory = cfg.source == cfg.factory_source | |
| if cfg.write and is_nameobj_factory then | |
| cfg.sync_create_table = true | |
| end | |
| do | |
| local cmd = "./build/tools/objdiff-cli report generate -o " .. shell_quote(cfg.report) | |
| local ok, out, code = run_capture(cmd) | |
| if cfg.verbose or not ok then | |
| printf("%s", out) | |
| end | |
| if not ok then | |
| fail("objdiff report generation failed (exit " .. tostring(code) .. ")") | |
| end | |
| end | |
| local report_text = read_file(cfg.report) | |
| if not report_text then | |
| fail("failed to read report file: " .. cfg.report) | |
| end | |
| local report, decode_err, json_null = decode_json(report_text) | |
| if not report then | |
| fail("failed to decode report JSON with " .. json_backend .. ": " .. tostring(decode_err)) | |
| end | |
| local function find_unit_by_source(report_data, source_path) | |
| local units = report_data and report_data.units | |
| if type(units) ~= "table" then | |
| return nil | |
| end | |
| for _, unit in ipairs(units) do | |
| local md = unit and unit.metadata | |
| if type(md) == "table" and md.source_path == source_path then | |
| return unit | |
| end | |
| end | |
| return nil | |
| end | |
| local unit = find_unit_by_source(report, cfg.source) | |
| if not unit then | |
| fail("could not find source in report: " .. cfg.source) | |
| end | |
| local fuzzy = tonumber(unit.measures and unit.measures.fuzzy_match_percent) | |
| if not fuzzy then | |
| fail("invalid fuzzy match value in report for source: " .. cfg.source) | |
| end | |
| printf("Source: %s\n", cfg.source) | |
| printf("Header: %s\n", cfg.header) | |
| printf("Fuzzy: %.4f%%\n", fuzzy) | |
| printf("JSON: %s\n", json_backend) | |
| if fuzzy >= 100.0 and not cfg.sync_create_table and not cfg.generate_stubs then | |
| printf("Already at 100%% fuzzy. No scaffolding needed.\n") | |
| os.exit(0) | |
| end | |
| local asm_text = nil | |
| if is_nameobj_factory and (cfg.sync_create_table or cfg.generate_stubs) then | |
| asm_text = read_file(cfg.factory_asm) | |
| if not asm_text then | |
| fail("failed to read factory asm: " .. cfg.factory_asm) | |
| end | |
| end | |
| local function symbol_to_creator_expr(symbol_ref) | |
| local sym = trim(symbol_ref) | |
| if sym == "0x00000000" then | |
| return "nullptr", nil, nil | |
| end | |
| sym = unquote(sym) | |
| local creator, class_name = sym:match("^(create[%a_]+)<%d*([A-Za-z_][A-Za-z0-9_]*)>__") | |
| if creator and class_name then | |
| return creator .. "<" .. class_name .. ">", class_name, creator | |
| end | |
| local mr_name = sym:match("^([A-Za-z_][A-Za-z0-9_]*)__2MR") | |
| if mr_name then | |
| return "MR::" .. mr_name, nil, nil | |
| end | |
| local plain = sym:match("^([A-Za-z_][A-Za-z0-9_]*)__") | |
| if plain then | |
| return plain, nil, nil | |
| end | |
| return sym, nil, nil | |
| end | |
| local function parse_asm_create_table(text) | |
| local in_table = false | |
| local words = {} | |
| for line in text:gmatch("([^\n\r]+)") do | |
| if not in_table then | |
| if line:find('.obj "cCreateTable__28@unnamed@NameObjFactory_cpp@", global', 1, true) then | |
| in_table = true | |
| end | |
| else | |
| if line:match("^%s*%.endobj") then | |
| break | |
| end | |
| local ref = line:match("^%s*%.4byte%s+(.+)$") | |
| if ref then | |
| table.insert(words, trim(ref)) | |
| end | |
| end | |
| end | |
| if #words == 0 then | |
| fail("failed to parse cCreateTable words from asm") | |
| end | |
| if (#words % 3) ~= 0 then | |
| fail("malformed cCreateTable words in asm: expected triple entries") | |
| end | |
| local entries = {} | |
| local creator_kinds = {} | |
| for i = 1, #words, 3 do | |
| local name_ref = words[i] | |
| local creator_ref = words[i + 1] | |
| local archive_ref = words[i + 2] | |
| local creator_expr, class_name, creator_kind = symbol_to_creator_expr(creator_ref) | |
| table.insert(entries, { | |
| name_ref = name_ref, | |
| creator = creator_expr, | |
| archive_ref = archive_ref, | |
| }) | |
| if class_name and creator_kind then | |
| creator_kinds[class_name] = creator_kinds[class_name] or {} | |
| creator_kinds[class_name][creator_kind] = true | |
| end | |
| end | |
| return entries, creator_kinds | |
| end | |
| local function parse_asm_creator_sizes(text) | |
| local sizes = {} | |
| local current_class = nil | |
| for line in text:gmatch("([^\n\r]+)") do | |
| local fn_sym = line:match('^%s*%.fn%s+"([^"]+)",') | |
| if fn_sym then | |
| local class_name = fn_sym:match("^create[%a_]+<%d*([A-Za-z_][A-Za-z0-9_]*)>__") | |
| current_class = class_name | |
| elseif line:match("^%s*%.endfn") then | |
| current_class = nil | |
| elseif current_class and not sizes[current_class] then | |
| local size_hex = line:match("li%s+r3,%s*0x([0-9A-Fa-f]+)") | |
| if size_hex then | |
| sizes[current_class] = tonumber(size_hex, 16) | |
| end | |
| end | |
| end | |
| return sizes | |
| end | |
| local function parse_number(token) | |
| token = trim(token) | |
| if token:match("^0x[0-9A-Fa-f]+$") then | |
| return tonumber(token, 16) | |
| end | |
| if token:match("^[0-9]+$") then | |
| return tonumber(token, 10) | |
| end | |
| return nil | |
| end | |
| local function u32_bytes_be(value) | |
| local b1 = math.floor(value / 0x1000000) % 0x100 | |
| local b2 = math.floor(value / 0x10000) % 0x100 | |
| local b3 = math.floor(value / 0x100) % 0x100 | |
| local b4 = value % 0x100 | |
| return b1, b2, b3, b4 | |
| end | |
| local function u16_bytes_be(value) | |
| local b1 = math.floor(value / 0x100) % 0x100 | |
| local b2 = value % 0x100 | |
| return b1, b2 | |
| end | |
| local function parse_asm_data_memory(text) | |
| local label_addr = {} | |
| local memory = {} | |
| local pending_addr = nil | |
| local pending_size = nil | |
| local current_label = nil | |
| local current_addr = nil | |
| local current_size = nil | |
| local current_bytes = nil | |
| local function flush_object() | |
| if not current_label or not current_addr then | |
| current_label = nil | |
| current_addr = nil | |
| current_size = nil | |
| current_bytes = nil | |
| return | |
| end | |
| local bytes = current_bytes or {} | |
| if current_size and current_size > 0 then | |
| if #bytes > current_size then | |
| while #bytes > current_size do | |
| table.remove(bytes) | |
| end | |
| elseif #bytes < current_size then | |
| for _ = #bytes + 1, current_size do | |
| table.insert(bytes, 0) | |
| end | |
| end | |
| end | |
| label_addr[current_label] = current_addr | |
| for i = 1, #bytes do | |
| memory[current_addr + (i - 1)] = bytes[i] | |
| end | |
| current_label = nil | |
| current_addr = nil | |
| current_size = nil | |
| current_bytes = nil | |
| end | |
| for line in text:gmatch("([^\n\r]+)") do | |
| local addr_hex, size_hex = line:match("^#%s+%.data:[^|]*|%s*0x([0-9A-Fa-f]+)%s*|%s*size:%s*0x([0-9A-Fa-f]+)") | |
| if addr_hex and size_hex then | |
| pending_addr = tonumber(addr_hex, 16) | |
| pending_size = tonumber(size_hex, 16) | |
| end | |
| local label = line:match('^%s*%.obj%s+([A-Za-z_][A-Za-z0-9_]*),') | |
| if not label then | |
| label = line:match('^%s*%.obj%s+"([^"]+)",') | |
| end | |
| if label then | |
| flush_object() | |
| current_label = label | |
| current_addr = pending_addr | |
| current_size = pending_size | |
| current_bytes = {} | |
| pending_addr = nil | |
| pending_size = nil | |
| end | |
| if current_bytes then | |
| local string_payload = line:match('^%s*%.string%s+"(.*)"') | |
| if string_payload then | |
| local text_value = unescape_asm_string(string_payload) | |
| for i = 1, #text_value do | |
| table.insert(current_bytes, text_value:byte(i)) | |
| end | |
| table.insert(current_bytes, 0) | |
| end | |
| local ascii_payload = line:match('^%s*%.ascii%s+"(.*)"') | |
| if ascii_payload then | |
| local text_value = unescape_asm_string(ascii_payload) | |
| for i = 1, #text_value do | |
| table.insert(current_bytes, text_value:byte(i)) | |
| end | |
| end | |
| local byte_payload = line:match('^%s*%.byte%s+(.+)$') | |
| if byte_payload then | |
| for token in byte_payload:gmatch("[^,]+") do | |
| local value = parse_number(token) | |
| if value then | |
| table.insert(current_bytes, value % 0x100) | |
| end | |
| end | |
| end | |
| local half_payload = line:match('^%s*%.2byte%s+(.+)$') | |
| if half_payload then | |
| for token in half_payload:gmatch("[^,]+") do | |
| local value = parse_number(token) | |
| if value then | |
| local b1, b2 = u16_bytes_be(value) | |
| table.insert(current_bytes, b1) | |
| table.insert(current_bytes, b2) | |
| else | |
| table.insert(current_bytes, 0) | |
| table.insert(current_bytes, 0) | |
| end | |
| end | |
| end | |
| local word_payload = line:match('^%s*%.4byte%s+(.+)$') | |
| if word_payload then | |
| for token in word_payload:gmatch("[^,]+") do | |
| local value = parse_number(token) | |
| if value then | |
| local b1, b2, b3, b4 = u32_bytes_be(value) | |
| table.insert(current_bytes, b1) | |
| table.insert(current_bytes, b2) | |
| table.insert(current_bytes, b3) | |
| table.insert(current_bytes, b4) | |
| else | |
| table.insert(current_bytes, 0) | |
| table.insert(current_bytes, 0) | |
| table.insert(current_bytes, 0) | |
| table.insert(current_bytes, 0) | |
| end | |
| end | |
| end | |
| end | |
| if line:match("^%s*%.endobj") then | |
| flush_object() | |
| end | |
| end | |
| flush_object() | |
| return label_addr, memory | |
| end | |
| local function resolve_data_label_cstring(label, label_addr, memory) | |
| local addr = label_addr[label] | |
| if not addr then | |
| fail("missing .data address for label: " .. tostring(label)) | |
| end | |
| local chars = {} | |
| local max_len = 0x800 | |
| for i = 0, max_len do | |
| local b = memory[addr + i] | |
| if b == nil then | |
| fail("failed to resolve string bytes for label: " .. tostring(label)) | |
| end | |
| if b == 0 then | |
| return table.concat(chars) | |
| end | |
| chars[#chars + 1] = string.char(b) | |
| end | |
| fail("unterminated string while resolving label: " .. tostring(label)) | |
| end | |
| local function find_initializer_range(text, declaration_line) | |
| local decl_start = text:find(declaration_line, 1, true) | |
| if not decl_start then | |
| return nil | |
| end | |
| local brace_start = text:find("{", decl_start + #declaration_line - 1, true) | |
| if not brace_start then | |
| return nil | |
| end | |
| local depth = 0 | |
| local in_string = false | |
| local escaped = false | |
| local close_brace = nil | |
| for pos = brace_start, #text do | |
| local ch = text:sub(pos, pos) | |
| if in_string then | |
| if escaped then | |
| escaped = false | |
| elseif ch == "\\" then | |
| escaped = true | |
| elseif ch == '"' then | |
| in_string = false | |
| end | |
| else | |
| if ch == '"' then | |
| in_string = true | |
| elseif ch == "{" then | |
| depth = depth + 1 | |
| elseif ch == "}" then | |
| depth = depth - 1 | |
| if depth == 0 then | |
| close_brace = pos | |
| break | |
| end | |
| end | |
| end | |
| end | |
| if not close_brace then | |
| return nil | |
| end | |
| local semicolon = text:find(";", close_brace, true) | |
| if not semicolon then | |
| return nil | |
| end | |
| return decl_start, semicolon | |
| end | |
| local function build_create_table_block(entries) | |
| local lines = { | |
| "const NameObjFactory::Name2CreateFunc cCreateTable[] = {", | |
| } | |
| for _, entry in ipairs(entries) do | |
| table.insert(lines, " {") | |
| table.insert(lines, ' "' .. escape_cpp_string(entry.name) .. '",') | |
| table.insert(lines, " " .. entry.creator .. ",") | |
| if entry.archive then | |
| table.insert(lines, ' "' .. escape_cpp_string(entry.archive) .. '",') | |
| else | |
| table.insert(lines, " nullptr,") | |
| end | |
| table.insert(lines, " },") | |
| end | |
| table.insert(lines, "};") | |
| return table.concat(lines, "\n") | |
| end | |
| local function is_header_or_source(path) | |
| return path:match("%.h$") ~= nil | |
| or path:match("%.hpp$") ~= nil | |
| or path:match("%.c$") ~= nil | |
| or path:match("%.cpp$") ~= nil | |
| or path:match("%.inc$") ~= nil | |
| end | |
| local function walk_dir(path, fn) | |
| for entry in lfs.dir(path) do | |
| if entry ~= "." and entry ~= ".." then | |
| local full = path .. "/" .. entry | |
| local attr = lfs.attributes(full) | |
| if attr then | |
| if attr.mode == "directory" then | |
| walk_dir(full, fn) | |
| elseif attr.mode == "file" then | |
| fn(full) | |
| end | |
| end | |
| end | |
| end | |
| end | |
| local function collect_class_names_from_text(text, out) | |
| local function is_ident_char(ch) | |
| return ch:match("[A-Za-z0-9_]") | |
| end | |
| local function scan(keyword) | |
| local pos = 1 | |
| while true do | |
| local s, e, name = text:find(keyword .. "%s+([A-Za-z_][A-Za-z0-9_]*)", pos) | |
| if not s then | |
| break | |
| end | |
| local i = e + 1 | |
| while i <= #text and text:sub(i, i):match("%s") do | |
| i = i + 1 | |
| end | |
| local maybe_final = text:sub(i, i + 4) | |
| local next_char = text:sub(i + 5, i + 5) | |
| if maybe_final == "final" and (next_char == "" or not is_ident_char(next_char)) then | |
| i = i + 5 | |
| while i <= #text and text:sub(i, i):match("%s") do | |
| i = i + 1 | |
| end | |
| end | |
| local ch = text:sub(i, i) | |
| if ch == ":" or ch == "{" then | |
| out[name] = true | |
| end | |
| pos = e + 1 | |
| end | |
| end | |
| scan("class") | |
| scan("struct") | |
| end | |
| local function path_is_under(path, parent) | |
| if not parent or parent == "" then | |
| return false | |
| end | |
| if path == parent then | |
| return true | |
| end | |
| local prefix = parent | |
| if prefix:sub(-1) ~= "/" then | |
| prefix = prefix .. "/" | |
| end | |
| return path:sub(1, #prefix) == prefix | |
| end | |
| local function strip_include_prefix(path) | |
| return path:gsub("^include/", "") | |
| end | |
| local function load_class_definition_map(skip_paths, skip_prefixes) | |
| local class_to_path = {} | |
| local roots = { "include", "src" } | |
| local skip_set = {} | |
| for _, path in ipairs(skip_paths or {}) do | |
| skip_set[path] = true | |
| end | |
| for _, root in ipairs(roots) do | |
| local attr = lfs.attributes(root) | |
| if attr and attr.mode == "directory" then | |
| walk_dir(root, function(path) | |
| if skip_set[path] then | |
| return | |
| end | |
| for _, prefix in ipairs(skip_prefixes or {}) do | |
| if path_is_under(path, prefix) then | |
| return | |
| end | |
| end | |
| if is_header_or_source(path) then | |
| local text = read_file(path) | |
| if text then | |
| local local_defs = {} | |
| collect_class_names_from_text(text, local_defs) | |
| for class_name in pairs(local_defs) do | |
| if not class_to_path[class_name] then | |
| class_to_path[class_name] = path | |
| end | |
| end | |
| end | |
| end | |
| end) | |
| end | |
| end | |
| return class_to_path | |
| end | |
| local function load_function_name_set(skip_paths) | |
| local names = {} | |
| local skip = {} | |
| for _, path in ipairs(skip_paths or {}) do | |
| skip[path] = true | |
| end | |
| local roots = { "include", "src" } | |
| for _, root in ipairs(roots) do | |
| local attr = lfs.attributes(root) | |
| if attr and attr.mode == "directory" then | |
| walk_dir(root, function(path) | |
| if skip[path] then | |
| return | |
| end | |
| if is_header_or_source(path) then | |
| local text = read_file(path) | |
| if text then | |
| for name in text:gmatch("([A-Za-z_][A-Za-z0-9_]*)%s*%(") do | |
| names[name] = true | |
| end | |
| end | |
| end | |
| end) | |
| end | |
| end | |
| return names | |
| end | |
| local function build_class_ctor_flags(creator_kinds, class_name) | |
| local kinds = creator_kinds[class_name] or {} | |
| local needs_name_ctor = kinds.createNameObj == true | |
| local needs_shape_ctor = kinds.createBaseOriginCube == true | |
| or kinds.createCenterOriginCube == true | |
| or kinds.createSphere == true | |
| or kinds.createBaseOriginCylinder == true | |
| or kinds.createBowl == true | |
| if not needs_name_ctor and not needs_shape_ctor then | |
| needs_name_ctor = true | |
| end | |
| return needs_name_ctor, needs_shape_ctor | |
| end | |
| local function float_literal(value) | |
| return string.format("%.1ff", value) | |
| end | |
| local function build_effect_stub_header(class_name, spec) | |
| local lines = { | |
| "#pragma once", | |
| "", | |
| '#include "Game/Effect/SimpleEffectObj.hpp"', | |
| "", | |
| "class " .. class_name .. " : public SimpleEffectObj {", | |
| "public:", | |
| " inline " .. class_name .. "(const char* pName) : SimpleEffectObj(pName) {}", | |
| "", | |
| " virtual ~" .. class_name .. "() {}", | |
| " virtual f32 getClippingRadius() const { return " .. float_literal(spec.radius) .. "; };", | |
| " virtual f32 getFarClipDistance() const { return " .. float_literal(spec.far) .. "; };", | |
| } | |
| if spec.center_y then | |
| table.insert(lines, "") | |
| table.insert(lines, " virtual TVec3f* getClippingCenterOffset() const {") | |
| table.insert(lines, " TVec3f vec(0.0f, " .. float_literal(spec.center_y) .. ", 0.0f);") | |
| table.insert(lines, " return &vec;") | |
| table.insert(lines, " };") | |
| end | |
| if spec.sync then | |
| table.insert(lines, " virtual bool isSyncClipping() const { return true; };") | |
| end | |
| table.insert(lines, "};") | |
| table.insert(lines, "") | |
| return table.concat(lines, "\n") | |
| end | |
| local function build_morph_item_base_stub_header() | |
| local lines = { | |
| "#pragma once", | |
| "", | |
| '#include "Game/NameObj/NameObj.hpp"', | |
| '#include "Game/LiveActor/Nerve.hpp"', | |
| "", | |
| "class MorphItemObjNeo : public NameObj {", | |
| "public:", | |
| " MorphItemObjNeo(const char*, long);", | |
| "", | |
| " virtual ~MorphItemObjNeo();", | |
| "", | |
| " void exeWait();", | |
| " void exeAppear();", | |
| " void exeSwitchAppear();", | |
| " void exeWait2();", | |
| " void exeFly();", | |
| " void exeDemo();", | |
| "", | |
| " /* 0x0C */ u8 _0C[0xFC - 0xC];", | |
| "};", | |
| "", | |
| "namespace NrvMorphItemObjNeo {", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvWait, MorphItemObjNeo, Wait);", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvAppear, MorphItemObjNeo, Appear);", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvSwitchAppear, MorphItemObjNeo, SwitchAppear);", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvWait2, MorphItemObjNeo, Wait2);", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvFly, MorphItemObjNeo, Fly);", | |
| " NERVE_DECL_EXE(MorphItemObjNeoNrvDemo, MorphItemObjNeo, Demo);", | |
| "};", | |
| "", | |
| } | |
| return table.concat(lines, "\n") | |
| end | |
| local function build_morph_item_stub_header(class_name, kind) | |
| local lines = { | |
| "#pragma once", | |
| "", | |
| '#include "Game/Player/MorphItemObjNeo.hpp"', | |
| "", | |
| "class " .. class_name .. " : public MorphItemObjNeo {", | |
| "public:", | |
| " inline " .. class_name .. "(const char* pName) : MorphItemObjNeo(pName, " .. tostring(kind) .. ") {}", | |
| "", | |
| " virtual ~" .. class_name .. "() {}", | |
| "};", | |
| "", | |
| } | |
| return table.concat(lines, "\n") | |
| end | |
| local function resolve_generated_header_path(class_name, cfg_data) | |
| return special_header_paths[class_name] or (cfg_data.stub_dir .. "/" .. class_name .. ".hpp") | |
| end | |
| local function build_class_stub_header(class_name, creator_kinds, size_map) | |
| if class_name == "MorphItemObjNeo" then | |
| return build_morph_item_base_stub_header() | |
| end | |
| local morph_kind = morph_item_kind_by_class[class_name] | |
| if morph_kind then | |
| return build_morph_item_stub_header(class_name, morph_kind) | |
| end | |
| local effect_spec = effect_stub_specs[class_name] | |
| if effect_spec then | |
| return build_effect_stub_header(class_name, effect_spec) | |
| end | |
| local lines = { | |
| "#pragma once", | |
| "", | |
| '#include "Game/NameObj/NameObj.hpp"', | |
| "", | |
| "class " .. class_name .. " : public NameObj {", | |
| "public:", | |
| } | |
| local needs_name_ctor, needs_shape_ctor = build_class_ctor_flags(creator_kinds, class_name) | |
| if needs_name_ctor then | |
| table.insert(lines, " " .. class_name .. "(const char*);") | |
| end | |
| if needs_shape_ctor then | |
| table.insert(lines, " " .. class_name .. "(int, const char*);") | |
| end | |
| local size = size_map[class_name] | |
| local base_size = 0xC | |
| if size then | |
| if size < base_size then | |
| fail(string.format("invalid parsed size for %s: 0x%X", class_name, size)) | |
| end | |
| if size > base_size then | |
| table.insert(lines, string.format(" /* 0x0C */ u8 _0C[0x%X - 0xC];", size)) | |
| end | |
| end | |
| table.insert(lines, "};") | |
| table.insert(lines, "") | |
| return table.concat(lines, "\n") | |
| end | |
| local function build_stub_header(include_paths, generated_header_paths, global_funcs, mr_funcs) | |
| local lines = { | |
| "#pragma once", | |
| "", | |
| '#include "Game/NameObj/NameObj.hpp"', | |
| "", | |
| } | |
| if include_paths and #include_paths > 0 then | |
| for _, include_path in ipairs(include_paths) do | |
| table.insert(lines, '#include "' .. strip_include_prefix(include_path) .. '"') | |
| end | |
| table.insert(lines, "") | |
| end | |
| if generated_header_paths and #generated_header_paths > 0 then | |
| for _, include_path in ipairs(generated_header_paths) do | |
| table.insert(lines, '#include "' .. strip_include_prefix(include_path) .. '"') | |
| end | |
| table.insert(lines, "") | |
| end | |
| if mr_funcs and #mr_funcs > 0 then | |
| table.insert(lines, "namespace MR {") | |
| for _, fn in ipairs(mr_funcs) do | |
| table.insert(lines, " NameObj* " .. fn .. "(const char*);") | |
| end | |
| table.insert(lines, "}") | |
| table.insert(lines, "") | |
| end | |
| if global_funcs and #global_funcs > 0 then | |
| for _, fn in ipairs(global_funcs) do | |
| table.insert(lines, "NameObj* " .. fn .. "(const char*);") | |
| end | |
| table.insert(lines, "") | |
| end | |
| return table.concat(lines, "\n") | |
| end | |
| local function ensure_directory(path) | |
| local ok, status = mkdir_p(path) | |
| if not ok then | |
| fail("failed to create directory: " .. path .. " (" .. tostring(status) .. ")") | |
| end | |
| end | |
| local function dirname(path) | |
| local dir = path:match("^(.*)/[^/]+$") | |
| if not dir or dir == "" then | |
| return "." | |
| end | |
| return dir | |
| end | |
| local asm_entries = nil | |
| local asm_creator_kinds = nil | |
| local asm_creator_sizes = nil | |
| local asm_label_addr = nil | |
| local asm_data_memory = nil | |
| if asm_text then | |
| asm_entries, asm_creator_kinds = parse_asm_create_table(asm_text) | |
| asm_creator_sizes = parse_asm_creator_sizes(asm_text) | |
| asm_label_addr, asm_data_memory = parse_asm_data_memory(asm_text) | |
| end | |
| if cfg.sync_create_table and not asm_entries then | |
| fail("sync-create-table requested but asm parse data is unavailable") | |
| end | |
| local unresolved_classes = {} | |
| if cfg.generate_stubs then | |
| if asm_creator_kinds then | |
| local set = {} | |
| for class_name in pairs(asm_creator_kinds) do | |
| set[class_name] = true | |
| end | |
| for class_name in pairs(set) do | |
| table.insert(unresolved_classes, class_name) | |
| end | |
| table.sort(unresolved_classes) | |
| else | |
| local function is_null(value) | |
| return value == nil or value == json_null | |
| end | |
| local funcs = unit and unit.functions | |
| if type(funcs) == "table" then | |
| local set = {} | |
| for _, func in ipairs(funcs) do | |
| local symbol = func and func.name | |
| if type(symbol) == "string" and is_null(func.fuzzy_match_percent) then | |
| local class_name = symbol:match("create[%a_]+<%d*([A-Za-z_][A-Za-z0-9_]*)>__") | |
| if class_name then | |
| set[class_name] = true | |
| end | |
| end | |
| end | |
| for class_name in pairs(set) do | |
| table.insert(unresolved_classes, class_name) | |
| end | |
| table.sort(unresolved_classes) | |
| end | |
| end | |
| printf("Template creator classes considered: %d\n", #unresolved_classes) | |
| end | |
| if cfg.generate_stubs and #unresolved_classes == 0 then | |
| printf("No class scaffolding candidates found.\n") | |
| if not cfg.sync_create_table then | |
| os.exit(0) | |
| end | |
| end | |
| local missing = {} | |
| local include_paths = {} | |
| local missing_mr_creators = {} | |
| local missing_global_creators = {} | |
| local generated_stub_header_paths = {} | |
| local generated_stub_header_texts = {} | |
| local unresolved_set = {} | |
| for _, class_name in ipairs(unresolved_classes) do | |
| unresolved_set[class_name] = true | |
| end | |
| if cfg.generate_stubs and #unresolved_classes > 0 then | |
| local class_map = load_class_definition_map( | |
| { cfg.stub_header, legacy_stub_header }, | |
| { cfg.stub_dir, legacy_stub_dir } | |
| ) | |
| local include_set = {} | |
| for _, class_name in ipairs(unresolved_classes) do | |
| local path = class_map[class_name] | |
| if force_stub_classes[class_name] then | |
| table.insert(missing, class_name) | |
| elseif path and path:match("^include/") then | |
| include_set[path] = true | |
| else | |
| table.insert(missing, class_name) | |
| end | |
| end | |
| local missing_set = {} | |
| for _, class_name in ipairs(missing) do | |
| missing_set[class_name] = true | |
| end | |
| local needs_morph_base = false | |
| for class_name in pairs(missing_set) do | |
| if morph_item_kind_by_class[class_name] then | |
| needs_morph_base = true | |
| break | |
| end | |
| end | |
| if needs_morph_base and not missing_set.MorphItemObjNeo then | |
| local morph_base_path = class_map.MorphItemObjNeo | |
| if morph_base_path and morph_base_path:match("^include/") then | |
| include_set[morph_base_path] = true | |
| else | |
| table.insert(missing, "MorphItemObjNeo") | |
| missing_set.MorphItemObjNeo = true | |
| end | |
| end | |
| for path in pairs(include_set) do | |
| table.insert(include_paths, path) | |
| end | |
| table.sort(include_paths) | |
| table.sort(missing) | |
| local generated_path_set = {} | |
| for _, class_name in ipairs(missing) do | |
| local header_path = resolve_generated_header_path(class_name, cfg) | |
| if not generated_path_set[header_path] then | |
| table.insert(generated_stub_header_paths, header_path) | |
| generated_path_set[header_path] = true | |
| end | |
| generated_stub_header_texts[header_path] = build_class_stub_header( | |
| class_name, | |
| asm_creator_kinds or {}, | |
| asm_creator_sizes or {} | |
| ) | |
| end | |
| table.sort(generated_stub_header_paths) | |
| if asm_entries then | |
| local fn_set = load_function_name_set({ cfg.source, cfg.stub_header, legacy_stub_header }) | |
| local mr_set = {} | |
| local global_set = {} | |
| for _, entry in ipairs(asm_entries) do | |
| local mr_fn = entry.creator:match("^MR::([A-Za-z_][A-Za-z0-9_]*)$") | |
| if mr_fn and not fn_set[mr_fn] then | |
| mr_set[mr_fn] = true | |
| end | |
| local plain_fn = entry.creator:match("^([A-Za-z_][A-Za-z0-9_]*)$") | |
| if plain_fn and plain_fn ~= "nullptr" and not fn_set[plain_fn] then | |
| global_set[plain_fn] = true | |
| end | |
| end | |
| for fn_name in pairs(mr_set) do | |
| table.insert(missing_mr_creators, fn_name) | |
| end | |
| for fn_name in pairs(global_set) do | |
| table.insert(missing_global_creators, fn_name) | |
| end | |
| table.sort(missing_mr_creators) | |
| table.sort(missing_global_creators) | |
| end | |
| printf("Missing class declarations: %d\n", #missing) | |
| printf("Extra headers to include in stub header: %d\n", #include_paths) | |
| printf("Generated per-class stub headers: %d\n", #generated_stub_header_paths) | |
| printf("Missing global creator declarations: %d\n", #missing_global_creators) | |
| printf("Missing MR creator declarations: %d\n", #missing_mr_creators) | |
| if cfg.verbose then | |
| for class_name in pairs(force_stub_classes) do | |
| printf( | |
| "ForceStub[%s]: unresolved=%s missing=%s\n", | |
| class_name, | |
| unresolved_set[class_name] and "yes" or "no", | |
| missing_set[class_name] and "yes" or "no" | |
| ) | |
| end | |
| end | |
| end | |
| local source_text = read_file(cfg.source) | |
| if not source_text then | |
| fail("failed to read source file: " .. cfg.source) | |
| end | |
| local patched_source = source_text | |
| local patched_table = false | |
| if cfg.sync_create_table then | |
| local decl = "const NameObjFactory::Name2CreateFunc cCreateTable[] = {" | |
| local s, e = find_initializer_range(patched_source, decl) | |
| if not s then | |
| fail("failed to locate cCreateTable initializer in source: " .. cfg.source) | |
| end | |
| local resolved_entries = {} | |
| for i = 1, #asm_entries do | |
| local entry = asm_entries[i] | |
| local name_ref = entry.name_ref | |
| local archive_ref = entry.archive_ref | |
| if not name_ref or name_ref == "0x00000000" then | |
| fail("invalid cCreateTable name reference at entry #" .. tostring(i)) | |
| end | |
| local name = resolve_data_label_cstring(name_ref, asm_label_addr, asm_data_memory) | |
| local archive = nil | |
| if archive_ref and archive_ref ~= "0x00000000" then | |
| archive = resolve_data_label_cstring(archive_ref, asm_label_addr, asm_data_memory) | |
| end | |
| resolved_entries[i] = { | |
| name = name, | |
| creator = entry.creator, | |
| archive = archive, | |
| } | |
| end | |
| local replacement = build_create_table_block(resolved_entries) | |
| patched_source = patched_source:sub(1, s - 1) .. replacement .. patched_source:sub(e + 1) | |
| patched_table = true | |
| end | |
| local stub_header_text = nil | |
| if cfg.generate_stubs then | |
| stub_header_text = build_stub_header( | |
| include_paths, | |
| generated_stub_header_paths, | |
| missing_global_creators, | |
| missing_mr_creators | |
| ) | |
| end | |
| if not cfg.write then | |
| if cfg.sync_create_table then | |
| printf("Dry-run only. cCreateTable sync is ready.\n") | |
| end | |
| if stub_header_text then | |
| printf("Dry-run only. Generated stub header preview:\n") | |
| local preview_lines = split_lines(stub_header_text) | |
| local max_preview = 30 | |
| for i = 1, math.min(#preview_lines, max_preview) do | |
| printf("%s\n", preview_lines[i]) | |
| end | |
| if #preview_lines > max_preview then | |
| printf("... (truncated preview)\n") | |
| end | |
| end | |
| if #generated_stub_header_paths > 0 then | |
| printf("Dry-run only. Generated per-class stub headers:\n") | |
| local max_preview = 20 | |
| for i = 1, math.min(#generated_stub_header_paths, max_preview) do | |
| printf("%s\n", generated_stub_header_paths[i]) | |
| end | |
| if #generated_stub_header_paths > max_preview then | |
| printf("... (truncated list)\n") | |
| end | |
| end | |
| printf("Re-run with --write to apply changes.\n") | |
| os.exit(0) | |
| end | |
| if stub_header_text then | |
| local stub_header_dir = cfg.stub_header:match("^(.*)/[^/]+$") or "." | |
| ensure_directory(stub_header_dir) | |
| local wrote_generated = 0 | |
| local kept_existing_generated = 0 | |
| for _, header_path in ipairs(generated_stub_header_paths) do | |
| ensure_directory(dirname(header_path)) | |
| local text = generated_stub_header_texts[header_path] | |
| if not text then | |
| fail("missing generated stub text for: " .. header_path) | |
| end | |
| if file_exists(header_path) then | |
| kept_existing_generated = kept_existing_generated + 1 | |
| else | |
| if not write_file(header_path, text) then | |
| fail("failed to write generated stub header: " .. header_path) | |
| end | |
| wrote_generated = wrote_generated + 1 | |
| end | |
| end | |
| if not write_file(cfg.stub_header, stub_header_text) then | |
| fail("failed to write stub header: " .. cfg.stub_header) | |
| end | |
| local include_line = '#include "' .. strip_include_prefix(cfg.stub_header) .. '"' | |
| patched_source = patched_source:gsub('#include "Game/NameObj/AutoNameObjFactoryStubs%.hpp"%s*\n', "") | |
| if not patched_source:find(include_line, 1, true) then | |
| local own_header = '#include "Game/NameObj/NameObjFactory.hpp"\n' | |
| local replacement = own_header .. include_line .. "\n" | |
| local patched, n = patched_source:gsub(own_header, replacement, 1) | |
| if n == 0 then | |
| patched, n = patched_source:gsub("((#include .-\n)+)", "%1" .. include_line .. "\n", 1) | |
| if n == 0 then | |
| fail("failed to inject stub include into: " .. cfg.source) | |
| end | |
| end | |
| patched_source = patched | |
| end | |
| printf("Wrote new generated per-class headers: %s\n", tostring(wrote_generated)) | |
| printf("Kept existing per-class headers without override: %s\n", tostring(kept_existing_generated)) | |
| end | |
| if patched_source ~= source_text then | |
| if not write_file(cfg.source, patched_source) then | |
| fail("failed to write patched source: " .. cfg.source) | |
| end | |
| end | |
| if patched_table then | |
| printf("Synced cCreateTable from asm.\n") | |
| end | |
| if stub_header_text then | |
| printf("Wrote stub header: %s\n", cfg.stub_header) | |
| printf("Wrote per-class stub headers: %s\n", tostring(#generated_stub_header_paths)) | |
| printf("Ensured stub include in: %s\n", cfg.source) | |
| end | |
| printf("Changes applied. Rebuild and re-run to measure fuzzy delta.\n") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment