From 330b2bae466267fc31db535f411d718b1e54d4e0 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:22:03 -0400 Subject: [PATCH] =?UTF-8?q?PC=20port:=20phase=202=20=E2=80=94=20Plan=20B?= =?UTF-8?q?=20executed=20(D50):=20offline=20model=20sidecars=20+=20port=20?= =?UTF-8?q?plumbing;=20texCopyGdls=20w1=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools_pc/d43_emit.py: full emit pass, 512/512 round-trip validated; data/pcmodels-/pcmodels.bin + manifest.csv (gitignored, regenerate) - port/src/pcmodels.c(+h): cart-extension reservation, sidecar copy, one-shot file_resource_table patch; romdata.c/h extended - objecthandler_2.c: #ifdef PORT hook — pcmodelsPatchTable() + poolRemaining=0 reset (closes D48.3/R6 reload hazard) - language.c/textrelated.c: runtime C fixups for lang-bank BE offset tables (romdataFixupLangBank) and font re-layout (romdataFontPcSize/FixupFont) - front.c: legal-screen uninitialized-pointer UB seed (N64 survived by luck) - tex.c (D50.6): texCopyGdls copied only w0 of each 16B Gfx slot on PC — compaction scratch kept stale mempool w1s -> garbage G_SETTIMG -> OOB in import_texture_rgba16. Full-slot copy under #ifdef PORT; byte-proven vs PlegalpageZ sidecar (w0s match, w1s stale). Model GDLs now execute. --- port/include/pcmodels.h | 46 ++ port/include/romdata.h | 19 + port/src/pcmodels.c | 236 +++++++++ port/src/romdata.c | 122 ++++- src/game/front.c | 11 + src/game/language.c | 29 ++ src/game/objecthandler_2.c | 21 + src/game/tex.c | 9 + src/game/textrelated.c | 30 ++ tools_pc/d43_emit.py | 956 +++++++++++++++++++++++++++++++++++++ tools_pc/d43_layoutprobe.c | 112 +++++ 11 files changed, 1588 insertions(+), 3 deletions(-) create mode 100644 port/include/pcmodels.h create mode 100644 port/src/pcmodels.c create mode 100644 tools_pc/d43_emit.py create mode 100644 tools_pc/d43_layoutprobe.c diff --git a/port/include/pcmodels.h b/port/include/pcmodels.h new file mode 100644 index 00000000..58eb96e4 --- /dev/null +++ b/port/include/pcmodels.h @@ -0,0 +1,46 @@ +#ifndef PORT_PCMODELS_H +#define PORT_PCMODELS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* D50 (docs/PCPortResearch.md): PC-layout model sidecars — Plan B (D48/D49). + * + * N64 ROM model files are 32-bit-pointer big-endian images the PC build + * cannot read in place (D37/D43). tools_pc/d43_emit.py converts all of them + * offline to PC-layout RZ sidecars: + * data/pcmodels-/pcmodels.bin single concatenated image + * data/pcmodels-/manifest.csv name,offset,size (decimal) + * + * romdataInit reserves romSize + pcmodelsReserveSize() at CART_BASE and + * pcmodelsLoadSidecars() copies the sidecar image to [CART_BASE+romSize, ...). + * pcmodelsPatchTable() — called once from load_object_fill_header, after + * obInit() has run — redirects every model entry's hw_address into the + * extension region and sets rom_size to the PC compressed size, so the + * existing romCopy/decompressdata load path serves PC bytes unmodified. + */ + +/* Bytes to reserve immediately after the ROM (0 = no sidecar image found; + * the game boots but model loads will fail). romImg is the validated ROM + * image; the country byte at +0x3E selects the region directory. */ +uint32_t pcmodelsReserveSize(const uint8_t *romImg); + +/* Copy the sidecar image to cartBase+romSize and parse the manifest. + * Returns 1 on success. */ +int pcmodelsLoadSidecars(uintptr_t cartBase, uint32_t romSize); + +/* One-shot patch of file_resource_table + resource_lookup_data_array for + * every manifest row. Must run after obInit() (obInit derives rom_size from + * adjacent hw_address deltas). No-op if the sidecar image is absent. */ +void pcmodelsPatchTable(void); + +uint32_t pcmodelsTotalSize(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PORT_PCMODELS_H */ diff --git a/port/include/romdata.h b/port/include/romdata.h index 57cedb9c..ec729e94 100644 --- a/port/include/romdata.h +++ b/port/include/romdata.h @@ -61,6 +61,25 @@ void romdataFixupAnimationData(u8 *blob, u32 blobSize, */ void romdataFixupMusicSeqTable(u8 *blob, u32 blobSize); +/* + * D50 (docs/PCPortResearch.md): decode a language bank's big-endian offset + * table in place ([u32 offsets x N][text]; see romdata.c). blobSize is the + * decompressed bank size (resource_lookup_data_array[].poolRemaining). + */ +void romdataFixupLangBank(u8 *blob, u32 blobSize); + +/* + * D50 (docs/PCPortResearch.md): re-lay out a font segment (struct font: + * kerning[169] + chars[94] + glyph pixel data) from the N64 ROM layout to + * the PC C layout, shifting the pixel block below the expanded char array. + * src/n64Size locate the N64 image (ROM segment); the caller allocates + * romdataFontPcSize() bytes, romCopies n64Size of them, then calls + * romdataFixupFont(). pixeldata fields are left as relative offsets — + * load_font_tables()'s `pixeldata += base` loop promotes them. + */ +u32 romdataFontPcSize(const u8 *src, u32 n64Size); +void romdataFixupFont(u8 *blob, u32 n64Size); + /* * D37 (docs/PCPortResearch.md): the libaudio SFX/instrument bank segments * (ALBankFile trees: ALBank -> ALInstrument -> ALSound -> ALWaveTable / diff --git a/port/src/pcmodels.c b/port/src/pcmodels.c new file mode 100644 index 00000000..67894136 --- /dev/null +++ b/port/src/pcmodels.c @@ -0,0 +1,236 @@ +/* + * PC-layout model sidecars (D50 / Plan B, D48/D49). + * + * Serves the offline-converted model files produced by tools_pc/d43_emit.py: + * data/pcmodels-/pcmodels.bin single concatenated RZ image + * data/pcmodels-/manifest.csv name,offset,size (decimal) + * + * The ROM is mapped at CART_BASE (romdata.c); the sidecar image is placed in + * a reservation extension at [CART_BASE+romSize, ...). romCopy() on PC is a + * host memcpy gated only by romdataCartAddrValid(), so once every model + * entry's hw_address points into the extension region and rom_size holds the + * PC compressed size, the existing load_resource()/decompressdata() path + * serves PC-layout bytes unmodified (and sets poolRemaining = D_PC). + * + * The table patch must run AFTER obInit(): obInit derives rom_size from + * adjacent-entry hw_address deltas (ob.c), so patching earlier would be + * overwritten. load_object_fill_header calls pcmodelsPatchTable() on every + * model load; it is a one-shot no-op after the first. + */ + +#include +#include + +/* D38: / resolve to the decomp's N64 stubs via the + * include path; declare the host functions this file uses. */ +extern void *malloc(unsigned long long size); +extern int snprintf(char *str, unsigned long long maxsize, const char *format, ...); + +#include "platform.h" +#include "system.h" +#include "fs.h" +#include "pcmodels.h" + +/* Order matters: must finish first — its PR/ucode.h anchor pulls + * in pc_protos.h, which itself includes game/ob.h. Including ob.h before + * ultra64.h leaves FILELOADMETHOD undefined mid-parse (circular guard). */ +#include /* u8/u32/s32 + full PR chain */ +#include "game/ob.h" /* fileentry, resource_lookup_data_entry */ + +/* Defined in src/game/ob.c (the table is included there from + * assets/obseg/file_resource_table.inc.c). Declared here rather than in ob.h + * so the game header stays untouched. */ +extern struct fileentry file_resource_table[]; +extern resource_lookup_data_entry resource_lookup_data_array[]; +extern s32 file_entry_max; + +#define PCMODES_CART_BASE 0x10000000u +#define PCMODES_MAX_FILES 1024 +#define PCMODES_NAME_LEN 64 + +struct pcmodelsRow { + char name[PCMODES_NAME_LEN]; + uint32_t offset; + uint32_t size; +}; + +static uint32_t s_total = 0; +static int s_loaded = 0; +static int s_patched = 0; +static uintptr_t s_base = 0; /* cart address of the sidecar image */ +static struct pcmodelsRow s_rows[PCMODES_MAX_FILES]; +static int s_rowCount = 0; +static char s_binPath[1024] = ""; +static char s_manPath[1024] = ""; + +static const char *pcmodelsRegionForCountry(u8 country) +{ + switch (country) { + case 'E': return "ntsc-final"; + case 'P': return "pal-final"; + case 'J': return "jpn-final"; + default: return NULL; + } +} + +/* Try $S/, $E/, ./ for ; on success copy the resolved path into out. */ +static int pcmodelsFindDataFile(const char *rel, char *out, size_t outsz) +{ + static const char *locs[3] = { "$S/", "$E/", "./" }; + int i; + for (i = 0; i < 3; i++) { + char pathbuf[1024]; + snprintf(pathbuf, sizeof(pathbuf), "%s%s", locs[i], rel); + if (fsExists(sysResolvePath(pathbuf))) { + snprintf(out, outsz, "%s", sysResolvePath(pathbuf)); + return 1; + } + } + return 0; +} + +uint32_t pcmodelsReserveSize(const uint8_t *romImg) +{ + const char *region = pcmodelsRegionForCountry(romImg[0x3E]); + char rel[256]; + + if (!region) + return 0; + + snprintf(rel, sizeof(rel), "pcmodels-%s/pcmodels.bin", region); + if (!pcmodelsFindDataFile(rel, s_binPath, sizeof(s_binPath))) { + sysLogPrintf(LOG_WARNING, + "pcmodels: %s not found — model loads will fail; run " + "\"python tools_pc/d43_emit.py\" (see docs/HANDOFF.md)", + rel); + return 0; + } + + snprintf(rel, sizeof(rel), "pcmodels-%s/manifest.csv", region); + if (!pcmodelsFindDataFile(rel, s_manPath, sizeof(s_manPath))) { + sysLogPrintf(LOG_ERROR, "pcmodels: manifest.csv missing next to %s", + s_binPath); + return 0; + } + + FSFile *f = fsOpen(s_binPath, "rb"); + if (!f) { + sysLogPrintf(LOG_ERROR, "pcmodels: cannot open %s", s_binPath); + return 0; + } + uint32_t size = (uint32_t)fsSize(f); + fsClose(f); + s_total = size; + return size; +} + +int pcmodelsLoadSidecars(uintptr_t cartBase, uint32_t romSize) +{ + FSFile *f; + int ok; + + if (!s_total) + return 0; + + f = fsOpen(s_binPath, "rb"); + if (!f) + return 0; + s_base = cartBase + romSize; + ok = fsRead(f, (void *)s_base, (int32_t)s_total) == (int32_t)s_total; + fsClose(f); + if (!ok) { + sysLogPrintf(LOG_ERROR, "pcmodels: short read of %s", s_binPath); + return 0; + } + + /* Parse the manifest (one-shot data for pcmodelsPatchTable). */ + f = fsOpen(s_manPath, "rb"); + if (!f) { + sysLogPrintf(LOG_ERROR, "pcmodels: cannot open %s", s_manPath); + return 0; + } + int32_t msize = fsSize(f); + char *buf = (char *)malloc((unsigned long long)(msize > 0 ? msize + 1 : 1)); + if (!buf) { + fsClose(f); + return 0; + } + int32_t nread = fsRead(f, buf, msize); + fsClose(f); + if (nread <= 0) { + free(buf); + sysLogPrintf(LOG_ERROR, "pcmodels: empty manifest %s", s_manPath); + return 0; + } + buf[nread] = 0; + + for (char *line = strtok(buf, "\r\n"); line; + line = strtok(NULL, "\r\n")) { + char *c1 = strchr(line, ','); + char *c2; + if (!c1 || !strcmp(line, "name,offset,size")) + continue; + *c1 = 0; + c2 = strchr(c1 + 1, ','); + if (!c2) + continue; + *c2 = 0; + if (s_rowCount >= PCMODES_MAX_FILES) { + sysLogPrintf(LOG_ERROR, "pcmodels: manifest exceeds %d rows", + PCMODES_MAX_FILES); + break; + } + struct pcmodelsRow *r = &s_rows[s_rowCount]; + snprintf(r->name, sizeof(r->name), "%s", line); + r->offset = (uint32_t)strtol(c1 + 1, NULL, 10); + r->size = (uint32_t)strtol(c2 + 1, NULL, 10); + s_rowCount++; + } + free(buf); + + if (!s_rowCount) { + sysLogPrintf(LOG_ERROR, "pcmodels: no rows parsed from %s", s_manPath); + return 0; + } + + s_loaded = 1; + sysLogPrintf(LOG_INFO, "pcmodels: %d sidecars (%u bytes) at 0x%llX", + s_rowCount, s_total, (unsigned long long)s_base); + return 1; +} + +void pcmodelsPatchTable(void) +{ + int i, t, hit; + + if (s_patched || !s_loaded) + return; + s_patched = 1; + + hit = 0; + for (i = 0; i < s_rowCount; i++) { + const struct pcmodelsRow *r = &s_rows[i]; + for (t = 1; t < file_entry_max; t++) { + if (file_resource_table[t].filename && + strcmp(file_resource_table[t].filename, r->name) == 0) { + file_resource_table[t].hw_address = + (u8 *)(s_base + r->offset); + resource_lookup_data_array[t].rom_size = r->size; + hit++; + break; + } + } + } + if (hit != s_rowCount) + sysLogPrintf(LOG_WARNING, + "pcmodels: patched %d/%d manifest rows (table mismatch?)", + hit, s_rowCount); + else + sysLogPrintf(LOG_INFO, "pcmodels: table patched (%d model entries)", + hit); +} + +uint32_t pcmodelsTotalSize(void) +{ + return s_loaded ? s_total : 0; +} diff --git a/port/src/romdata.c b/port/src/romdata.c index 91fc41e4..f5a21370 100644 --- a/port/src/romdata.c +++ b/port/src/romdata.c @@ -37,6 +37,12 @@ extern int snprintf(char *str, unsigned long long maxsize, const char *format, . #include "system.h" #include "fs.h" #include "romdata.h" +#include "pcmodels.h" + +/* D50: struct font/fontchar layouts for the font-segment re-layout. Order + * matters (see pcmodels.c): ultra64.h must finish before bondtypes.h. */ +#include +#include "bondtypes.h" #define CART_BASE 0x10000000u @@ -151,16 +157,24 @@ int romdataInit(void) continue; } + /* D50: reserve room for the PC model sidecar image right after the + * ROM ([CART_BASE+romSize, ...)); it is loaded below and patched into + * file_resource_table after obInit. */ + u32 sideTotal = pcmodelsReserveSize(img); + /* Map at the cart address so absolute asset symbols are live. */ #if defined(PLATFORM_WINDOWS) - void *at = VirtualAlloc((LPVOID)CART_BASE, romSize, + void *at = VirtualAlloc((LPVOID)CART_BASE, romSize + sideTotal, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); if (at == (void *)(uintptr_t)CART_BASE) { memcpy(at, img, romSize); free(img); mappedAtCartBase = 1; sysLogPrintf(LOG_INFO, "romdataInit: %s (%u bytes) mapped at " - "0x%08X (cart base)", tok, romSize, CART_BASE); + "0x%08X (cart base)%s", + tok, romSize, CART_BASE, + sideTotal ? ", + model sidecars" : ""); + pcmodelsLoadSidecars(CART_BASE, romSize); return 0; } if (at) @@ -216,7 +230,8 @@ int romdataCartAddrValid(u32 addr, u32 size) if (addr < CART_BASE) return 0; u32 off = addr - CART_BASE; - return off + size <= romSize; + /* D50: the model sidecar image extends the valid cart region. */ + return off + size <= romSize + pcmodelsTotalSize(); } const void *romdataMapVa(u32 va) @@ -259,6 +274,10 @@ static u32 romdataBswap32(u32 v) ((v & 0x00FF0000u) >> 8) | ((v & 0xFF000000u) >> 24); } +/* The port/shim is the N64 stub for C TUs (no offsetof); this is + * the equivalent null-cast idiom. */ +#define ROMDATA_OFFSEXP(T, M) ((u32)&(((T *)0)->M)) + static u16 romdataBswap16(u16 v) { return (u16)((v << 8) | (v >> 8)); @@ -353,6 +372,103 @@ void romdataFixupMusicSeqTable(u8 *blob, u32 blobSize) } } +/* + * D50 (docs/PCPortResearch.md): a language bank is [u32 offsets x N][text]. + * Offsets are big-endian and point into the text region; langGet() reads them + * little-endian on PC and would build garbage pointers. Scan from the front + * and bswap word i while bswap32(word_i) is a plausible offset (strictly past + * its own position, before the end of the blob). Zero entries stay zero + * ("string not loaded" — langGet returns NULL). The scan stops at the first + * text word: printable ASCII byte-swaps to >= 0x20000000, always >= blobSize + * (banks are < 64 KB). All-zero placeholder banks (e.g. LameE) bswap nothing. + */ +void romdataFixupLangBank(u8 *blob, u32 blobSize) +{ + u32 i = 0; + while (i * 4 + 4 <= blobSize) { + u32 v = romdataBswap32(*(const u32 *)(blob + i * 4)); + if (v != 0 && (v <= i * 4 || v >= blobSize)) + break; + *(u32 *)(blob + i * 4) = v; + i++; + } +} + +/* + * D50 (docs/PCPortResearch.md): font segments (Zurich Bold, Bank Gothic) + * serialize `struct font` in the N64 layout — big-endian scalars, 4-byte + * pointers: + * [s32 kerning x 169][fontchar x 94 @ stride 24][glyph pixel data] + * where fontchar = {s32 index, baseline, height, width, kerningindex; + * u32 pixeldata-offset-from-font-base}. The PC C layout is + * {5 s32; u64 pixeldata} @ stride sizeof(fontchar) with chars at + * ROMDATA_OFFSEXP(struct font, chars) — and that region overlaps the start of the + * pixel data in ROM (chars end 0xB74, pixels start 0xB80, PC chars end + * 0xE78), so the re-layout must also shift the pixel data down. + * + * Transform: bswap kerning in place; memmove the pixel block from + * [pixStart, n64Size) to [pcCharsEnd, ...); convert each char entry + * (backward — PC stride 32 > N64 stride 24, dst always past src) with + * pixeldata stored as a zero-extended RELATIVE offset into the new image, + * so load_font_tables()'s existing `pixeldata += base` loop promotes it + * (N64 pattern, D37-style). The caller must allocate romdataFontPcSize() + * bytes and romCopy only n64Size of them. + */ +/* src is the N64-layout image (the ROM segment); n64Size its size. */ +u32 romdataFontPcSize(const u8 *src, u32 n64Size) +{ + enum { N64_COFF = 169 * 4, N64_CSTRIDE = 24, NCHARS = 94 }; + u32 i, n, pixStart = 0; + + if (n64Size <= N64_COFF) + return n64Size; + n = (n64Size - N64_COFF) / N64_CSTRIDE; + if (n > NCHARS) + n = NCHARS; + for (i = 0; i < n; i++) { + u32 o = romdataBswap32(*(const u32 *)(src + N64_COFF + N64_CSTRIDE * i + 20)); + if (o != 0 && (pixStart == 0 || o < pixStart)) + pixStart = o; + } + return (u32)(ROMDATA_OFFSEXP(struct font, chars) + NCHARS * sizeof(struct fontchar)) + + n64Size - pixStart; +} + +void romdataFixupFont(u8 *blob, u32 n64Size) +{ + enum { N64_KERN = 169, N64_COFF = 169 * 4, N64_CSTRIDE = 24, NCHARS = 94 }; + u32 i, n, pixStart = 0, pcPixOff; + + for (i = 0; i < N64_KERN; i++) + *(u32 *)(blob + 4 * i) = romdataBswap32(*(const u32 *)(blob + 4 * i)); + + n = (n64Size - N64_COFF) / N64_CSTRIDE; + if (n > NCHARS) + n = NCHARS; + for (i = 0; i < n; i++) { + u32 o = romdataBswap32(*(const u32 *)(blob + N64_COFF + N64_CSTRIDE * i + 20)); + if (o != 0 && (pixStart == 0 || o < pixStart)) + pixStart = o; + } + + pcPixOff = (u32)(ROMDATA_OFFSEXP(struct font, chars) + NCHARS * sizeof(struct fontchar)); + if (pixStart != 0 && pixStart < n64Size) + memmove(blob + pcPixOff, blob + pixStart, n64Size - pixStart); + + for (i = n; i-- > 0;) { + const u8 *s = blob + N64_COFF + N64_CSTRIDE * i; + u8 *d = blob + ROMDATA_OFFSEXP(struct font, chars) + sizeof(struct fontchar) * i; + u32 k, o; + for (k = 0; k < 5; k++) + *(u32 *)(d + 4 * k) = romdataBswap32(*(const u32 *)(s + 4 * k)); + o = romdataBswap32(*(const u32 *)(s + 20)); + if (o != 0 && pixStart != 0 && o >= pixStart) + o = pcPixOff + (o - pixStart); + *(u32 *)(d + 20) = o; /* zero-extended relative offset */ + *(u32 *)(d + 24) = 0; + } +} + /* * D37 (docs/PCPortResearch.md): convert a ROM-serialized libaudio bank tree * (ALBankFile -> ALBank -> ALInstrument -> ALSound -> ALWaveTable / diff --git a/src/game/front.c b/src/game/front.c index d51971d9..24d49acf 100644 --- a/src/game/front.c +++ b/src/game/front.c @@ -1506,7 +1506,18 @@ Gfx *constructor_menu00_legalscreen(Gfx *DL) s32 i; u8 *txt; Mtxf tmpmtx; +#ifdef PORT + /* PC port (D50): on N64 this pointer is used uninitialized at the lookat + * call below — the R4300 register happens to hold a readable address and + * the result is zeroed by `* 0.0f` anyway. The x86-64 compiler folds the + * UB read to NULL (cvtsi2ssl 0x4) and faults. Seeding it with the array + * the function assigns a few lines later keeps the value deterministic; + * v_pos is s32 so `v_pos * 0.0f` is ±0.0f either way — identical lookat. + * N64 build untouched (no -DPORT). */ + struct legal_screen_text *legal_text_ptr = legalpage_text_array; +#else struct legal_screen_text *legal_text_ptr; +#endif renderdata = legalscreen_MRD; diff --git a/src/game/language.c b/src/game/language.c index 4ee6fc45..74208bdb 100644 --- a/src/game/language.c +++ b/src/game/language.c @@ -4,6 +4,20 @@ #include "language.h" #include "ob.h" +#ifdef PORT +/* D50: language banks carry a big-endian offset table; decode it in place + * after each load (see romdataFixupLangBank). */ +#include "romdata.h" +extern resource_lookup_data_entry resource_lookup_data_array[]; /* ob.c */ + +static void langFixupLoadedBank(char *name, void *p) +{ + s32 idx = fileGetIndex(name); + if (idx >= 0 && resource_lookup_data_array[idx].poolRemaining != 0) + romdataFixupLangBank((u8 *)p, resource_lookup_data_array[idx].poolRemaining); +} +#endif + // bss //CODE.bss:8008C640 s32 g_LangBanks[45]; @@ -249,6 +263,15 @@ void langInit(void) { g_LangBanks[LMPWEAPONS] = _fileNameLoadToBank(LnameX_lookuptable[LMPWEAPONS][j_text_trigger], FILELOADMETHOD_DEFAULT, 0x100, MEMPOOL_PERMANENT); g_LangBanks[LOPTIONS] = _fileNameLoadToBank(LnameX_lookuptable[LOPTIONS][j_text_trigger], FILELOADMETHOD_DEFAULT, 0x100, MEMPOOL_PERMANENT); g_LangBanks[LMISC] = _fileNameLoadToBank(LnameX_lookuptable[LMISC][j_text_trigger], FILELOADMETHOD_DEFAULT, 0x100, MEMPOOL_PERMANENT); +#ifdef PORT + langFixupLoadedBank(LnameX_lookuptable[LGUN][j_text_trigger], (void *)g_LangBanks[LGUN]); + langFixupLoadedBank(LnameX_lookuptable[LTITLE][j_text_trigger], (void *)g_LangBanks[LTITLE]); + langFixupLoadedBank(LnameX_lookuptable[LMPMENU][j_text_trigger], (void *)g_LangBanks[LMPMENU]); + langFixupLoadedBank(LnameX_lookuptable[LPROPOBJ][j_text_trigger], (void *)g_LangBanks[LPROPOBJ]); + langFixupLoadedBank(LnameX_lookuptable[LMPWEAPONS][j_text_trigger], (void *)g_LangBanks[LMPWEAPONS]); + langFixupLoadedBank(LnameX_lookuptable[LOPTIONS][j_text_trigger], (void *)g_LangBanks[LOPTIONS]); + langFixupLoadedBank(LnameX_lookuptable[LMISC][j_text_trigger], (void *)g_LangBanks[LMISC]); +#endif } @@ -354,12 +377,18 @@ struct jpncharpixels *langGetJpnCharPixels(s32 codepoint) void langLoadToAddr(u32 id) { g_LangBanks[id] = _fileNameLoadToBank(LnameX_lookuptable[id][j_text_trigger],1,0x100,MEMPOOL_STAGE); +#ifdef PORT + langFixupLoadedBank(LnameX_lookuptable[id][j_text_trigger], (void *)g_LangBanks[id]); +#endif } void langLoadToBank(int id,u8 *target,int size) { g_LangBanks[id] = _fileNameLoadToAddr(LnameX_lookuptable[id][j_text_trigger],1,target,size); +#ifdef PORT + langFixupLoadedBank(LnameX_lookuptable[id][j_text_trigger], (void *)g_LangBanks[id]); +#endif } diff --git a/src/game/objecthandler_2.c b/src/game/objecthandler_2.c index e567e132..1d7a1d81 100644 --- a/src/game/objecthandler_2.c +++ b/src/game/objecthandler_2.c @@ -11,6 +11,11 @@ #include "quaternion.h" #include "tex.h" +#ifdef PORT +#include "pcmodels.h" /* D50: PC-layout model sidecars (Plan B, D48/D49) */ +extern resource_lookup_data_entry resource_lookup_data_array[]; /* ob.c */ +#endif + /*** * Perfect Dark: @@ -90,6 +95,22 @@ void load_object_fill_header(struct ModelFileHeader *objheader, u8 *name, u8* ds { void *filedata; +#ifdef PORT + /* PC port (D50, Plan B D48/D49): serve PC-layout model sidecars from the + * cart extension region. One-shot table patch (safe to call repeatedly); + * by first model load obInit() has definitely run (boss.c:179). Must run + * before _fileNameLoadTo* below reads hw_address/rom_size. */ + pcmodelsPatchTable(); + if (dst == 0) { + /* D48.3: a stale poolRemaining from an earlier load of this file + * would under-allocate the fresh bank chunk (P_old < round8(C_pc)+8) + * and load_resource would fail with poolRemaining=0. Resetting to 0 + * forces the full-bank allocation; the bump allocator ends at the + * same cursor either way (mempAddEntryOfSizeToBank shrinks it back). */ + resource_lookup_data_array[fileGetIndex((char *)name)].poolRemaining = 0; + } +#endif + if (dst != 0) { filedata = _fileNameLoadToAddr(name, 0, dst, size); diff --git a/src/game/tex.c b/src/game/tex.c index ea5d16ae..ccfa13a6 100644 --- a/src/game/tex.c +++ b/src/game/tex.c @@ -1072,7 +1072,16 @@ void texCopyGdls(Gfx *arg0, Gfx *arg1, s32 arg2) while (arg2--) { +#ifdef PORT + /* PC port (D50): on x86-64 the trailing `long long` union member spans + * only the first 8 of 16 slot bytes; assigning it leaves w1 stale. + * texLoadFromGdl() then reads the half-copied scratch copy and + * propagates garbage w1s into the final GDLs (seg_addr -> OOB read in + * import_texture_rgba16). Copy the whole 16-byte slot. */ + *arg1 = *arg0; +#else arg1->force_structure_alignment = arg0->force_structure_alignment; +#endif arg1--; arg0--; } diff --git a/src/game/textrelated.c b/src/game/textrelated.c index 455e922d..bc4e7200 100644 --- a/src/game/textrelated.c +++ b/src/game/textrelated.c @@ -4,6 +4,12 @@ #include "bondtypes.h" #include "game/language.h" +#ifdef PORT +/* D50: font segments are re-laid out from the N64 ROM layout to the PC C + * layout (see romdataFixupFont). */ +#include "romdata.h" +#endif + #define SPACE_WIDTH 5 #define M_COLOR_R(x) (u8)(x >> 0x18) @@ -108,22 +114,46 @@ void load_font_tables(void) text_s = 0; text_t = 0; + len = (romptr_t)&_fontbankgothicSegmentEnd - (romptr_t)&_fontbankgothicSegmentStart; +#ifdef PORT + { + u32 n64len = len; + ptrFontBankGothic = (struct font *)mempAllocBytesInBank(romdataFontPcSize((const u8 *)&_fontbankgothicSegmentRomStart, n64len), MEMPOOL_STAGE); + ptrFontBankGothicChars = ptrFontBankGothic->chars; + + romCopy(ptrFontBankGothic, (void *) &_fontbankgothicSegmentRomStart, n64len); + romdataFixupFont((u8 *)ptrFontBankGothic, n64len); + } +#else len = (romptr_t)&_fontbankgothicSegmentEnd - (romptr_t)&_fontbankgothicSegmentStart; ptrFontBankGothic = (struct font *)mempAllocBytesInBank(len, MEMPOOL_STAGE); ptrFontBankGothicChars = ptrFontBankGothic->chars; romCopy(ptrFontBankGothic, (void *) &_fontbankgothicSegmentRomStart, len); +#endif // Convert pointers for (i = 0; i < 94; i++) { ptrFontBankGothicChars[i].pixeldata += (uintptr_t)ptrFontBankGothic; } + len = (romptr_t)&_fontzurichboldSegmentEnd - (romptr_t)&_fontzurichboldSegmentStart; +#ifdef PORT + { + u32 n64len = len; + ptrFontZurichBold = (struct font *)mempAllocBytesInBank(romdataFontPcSize((const u8 *)&_fontzurichboldSegmentRomStart, n64len), MEMPOOL_STAGE); + ptrFontZurichBoldChars = ptrFontZurichBold->chars; + + romCopy(ptrFontZurichBold, (void *) &_fontzurichboldSegmentRomStart, n64len); + romdataFixupFont((u8 *)ptrFontZurichBold, n64len); + } +#else len = (romptr_t)&_fontzurichboldSegmentEnd - (romptr_t)&_fontzurichboldSegmentStart; ptrFontZurichBold = (struct font *)mempAllocBytesInBank(len, MEMPOOL_STAGE); ptrFontZurichBoldChars = ptrFontZurichBold->chars; romCopy(ptrFontZurichBold, (void *) &_fontzurichboldSegmentRomStart, len); +#endif // Convert pointers for (i = 0; i < 94; i++) { diff --git a/tools_pc/d43_emit.py b/tools_pc/d43_emit.py new file mode 100644 index 00000000..f21de03b --- /dev/null +++ b/tools_pc/d43_emit.py @@ -0,0 +1,956 @@ +#!/usr/bin/env python3 +"""D50 / Plan B (D48/D49): offline emit pass — convert all N64 model files to +PC layout and write RZ-compressed sidecars + manifest.csv. + +Per file: + 1. decompress the N64 image from the ROM (filelist.u.csv row) + 2. build mutable node map; run the EXACT modelIterateDisplayLists visit + simulation (d43_gdlorder logic: LOD/SWITCH rewire, BSP splice) -> gdl_seq + 3. layout pass (d43_convert placement): [switches NS*8][texconfigs NT*12] + [DFS nodes 48B + records PC_REC + vtx arrays][zero-vtx][GDLs packed in + gdl_seq order, 16B per N64 slot] -> D_PC, region map + 4. emit pass: byte-exact field tables (bswap32/16, promoted ptrs -> u64 + zero-ext 0x05xxxxxx VMA, GDL Primary/Secondary raw VMAs, BaseAddr=0) + 5. round-trip validation: re-parse the emitted image and check every field + against the N64 source (inverse remap), plus region tiling (no gaps) + 6. compress: 0x11 0x72 + raw deflate (level 6) -> C_PC + +Cross-checks (all must pass): + - per-file dst!=0 buffers: round8(C)+8 <= buf AND D+round8(C) <= buf + (inflate overlap safety); N64 sanity version of the same checks + - G* hand weapons worst-case vs 0xF000 + - chains (cast / title / bondview): cumulative P_final bound + B_pc + 2*(R_share_N64 - B_n64) per slot, worst over candidate sets + - totals: sum round8(C), max C, max D + +Output: data/pcmodels-/pcmodels.bin (single concatenated image, +sidecars at 16-aligned offsets) + manifest.csv (name,offset,size decimal; +file_resource_table.inc.c order). The port layer (port/src/pcmodels.c) +copies pcmodels.bin to [CART_BASE+romSize, ...) and patches +file_resource_table[i].hw_address / rom_size per manifest row. +""" +import csv, struct, zlib, os, re, sys, bisect +from collections import Counter + +# ---------------------------------------------------------------- config --- +REGION = "ntsc-final" +CHECK_ONLY = False +if len(sys.argv) > 1: + if sys.argv[1] == "--check-only": + CHECK_ONLY = True + elif sys.argv[1] in ("ntsc-final", "pal-final", "jpn-final"): + REGION = sys.argv[1] + +ROM_PATH = f"data/ge007.{REGION}.z64" +OUT_DIR = f"data/pcmodels-{REGION}" +TABLE = "assets/obseg/file_resource_table.inc.c" +FILELIST = "scripts/filelist.u.csv" + +rom = open(ROM_PATH, "rb").read() +rows = list(csv.reader(open(FILELIST))) + +# nsnt from assets/**/modelFileHeader.inc.c (case-insensitive match) +nsnt = {} +for root, dirs, files in os.walk("assets"): + for fn in files: + if fn.lower().endswith("modelfileheader.inc.c"): + for line in open(os.path.join(root, fn)): + m = re.search(r"MODELFILEHEADER\((.*)\)\s*;?\s*$", line) + if not m: + continue + a = [x.strip() for x in m.group(1).split(",")] + if len(a) < 9: + continue + try: + nsnt[a[0]] = (int(a[4], 0), int(a[8], 0)) + except ValueError: + pass + +# file_resource_table order (manifest order) + which entries are model files +table_names = [] +for line in open(TABLE): + m = re.search(r'\{\s*\w+\s*,\s*"([^"]+)"', line) + if m: + table_names.append(m.group(1)) + +def is_model_name(n): + return n and n[0] in "CGP" and n.endswith("Z") and "_stan" not in n + +# filelist lookup by basename (with .bin stripped) +fl_by_base = {} +for r in rows: + if len(r) < 3 or not r[2]: + continue + base = r[2].rsplit("/", 1)[-1] + if base.endswith(".bin"): + base = base[:-4] + fl_by_base[base] = (int(r[0]), int(r[1])) + +def find_row(name): + for cand in (name, name[1:], name[:-1], name[1:-1]): + if cand in fl_by_base: + return cand, fl_by_base[cand] + return None, None + +# ---------------------------------------------------------------- helpers --- +MASK = 0xFFFFFF +def be16(b, o): return struct.unpack_from(">h", b, o)[0] +def bu16(b, o): return struct.unpack_from(">H", b, o)[0] +def be32o(b, o): return struct.unpack_from(">I", b, o)[0] & MASK +def be32r(b, o): return struct.unpack_from(">I", b, o)[0] + +N64_REC = {1: 0x1C, 2: 0x1C, 3: 0x1C, 4: 0x14, 8: 0x10, 9: 0x24, 10: 0x1C, + 12: 0x28, 13: 0x20, 15: 0x1C, 17: 0x20, 18: 0x08, 21: 0x14, + 22: 0x10, 23: 0x02, 24: 0x20} +PC_REC = {1: 24, 2: 40, 3: 40, 4: 40, 8: 24, 9: 48, 10: 28, 12: 48, + 13: 48, 15: 28, 18: 16, 21: 20, 22: 32, 23: 2, 24: 64} +PC_NODE = 48 +# GDL w1 remap set: only these opcodes carry file-relative seg-5 addresses in +# w1. Verified against the ROM (all 512 files): G_VTX w1 is 0x04xxxxxx (seg 4 = +# runtime vtx buffer, game-set — leave) or 0x05xxxxxx (file-relative — remap); +# G_SETTIMG w1 is 0x05xxxxxx (embedded image blob — remap). G_LOADBLOCK (0xF3) +# w1=0x07xxxxxx is ignored by port/fast3d (fixed dpLoadBlock) — leave. All +# other opcodes carry index/mode data in w1 (GE redefined the immediate range: +# 0xB1 is a triangle command with 8-bit indices, NOT G_DL) — leave. +ADDR_OPS = {0x04, 0xFD} + +def round8(x): return (x + 7) & ~7 +def round16(x): return (x + 15) & ~15 +def round64(x): return (x + 63) & ~63 + +# ------------------------------------------------- node map + visit sim ---- +class N: + __slots__ = ("op", "data", "parent", "next", "prev", "child") + +def build_nodes(src, D, NS, NT): + """Mutable node map. Closure MUST match d43_convert.walk() exactly: + op8/op18 follow their Affects/Controls pointer INSTEAD of child (their + Child/Next fields are not tree links and lead into garbage).""" + R0 = 4 * NS + 12 * NT + nodes = {} + stack = [R0] + while stack: + o = stack.pop() + if o in nodes or o >= D: + continue + n = N() + n.op = bu16(src, o) & 0xff + n.data = be32o(src, o + 4) + n.parent = be32o(src, o + 8) + n.next = be32o(src, o + 0xC) + n.prev = be32o(src, o + 0x10) + n.child = be32o(src, o + 0x14) + nodes[o] = n + if n.op == 8: + q = be32o(src, n.data + 8) + if q: + stack.append(q) + elif n.op == 18: + q = be32o(src, n.data + 0) + if q: + stack.append(q) + else: + if n.child: + stack.append(n.child) + if n.next: + stack.append(n.next) + return nodes, R0 + +def gdl_of(src, n): + if n.op in (4, 24): + return be32o(src, n.data + 0), be32o(src, n.data + 4) + if n.op == 22: + return be32o(src, n.data + 8), 0 + return 0, 0 + +def visit_seq(nodes, src, R0): + """EXACT modelIterateDisplayLists GDL visit order (mutable walk).""" + seq = [] + node = R0 + prev_node = None + prev_gdl = 0 + guard = 0 + while node and guard < 200000: + guard += 1 + n = nodes[node] + gdl = 0 + if n.op in (4, 22, 24): + p, s = gdl_of(src, n) + if node != prev_node: + gdl = p + elif s and s != prev_gdl: + gdl = s + elif n.op == 8: # LOD rewire + aff = be32o(src, n.data + 8) + n.child = aff + elif n.op == 18: # SWITCH rewire + ctl = be32o(src, n.data + 0) + n.child = ctl + elif n.op == 9: # BSP splice (visible=TRUE) + lc, rc = be32o(src, n.data + 0x18), be32o(src, n.data + 0x1C) + node1, node2 = lc, rc + if node1: + n.child = node1 + nodes[node1].prev = 0 + loop = node1 + while nodes[loop].next and nodes[loop].next != node2: + loop = nodes[loop].next + nodes[loop].next = node2 + if node2: + nodes[node2].prev = loop + loop = node2 + while nodes[loop].next and nodes[loop].next != node1: + loop = nodes[loop].next + nodes[loop].next = 0 + else: + n.child = node2 + if node2: + nodes[node2].prev = 0 + if gdl: + seq.append(gdl) + prev_node = node + prev_gdl = gdl + continue + prev_node = None + if n.child: + node = n.child + else: + while node: + nn = nodes[node].next + if nn: + node = nn + break + node = nodes[node].parent + return seq + +def placement_order(nodes, src, R0): + """d43_convert walk(): next-first DFS stack (node placement order only; + GDL packing uses visit_seq). Returns [(n64_off, op)] in placement order.""" + seen = set() + stack = [R0] + out = [] + while stack: + o = stack.pop() + if o in seen or o >= len(src): + continue + seen.add(o) + n = nodes[o] + out.append((o, n.op)) + child, nxt = n.child, n.next + if n.op == 8: + a = be32o(src, n.data + 8) + if a: + stack.append(a) + elif n.op == 18: + c = be32o(src, n.data + 0) + if c: + stack.append(c) + else: + if child: + stack.append(child) + if nxt: + stack.append(nxt) + return out + +def gdl_end(src, D, g): + o = g + while o + 8 <= D: + if (be32r(src, o) >> 24) == 0xB8: + return o + 8 + o += 8 + return D + +# ------------------------------------------------------------- per file ---- +errors = [] +stats = Counter() +results = {} # name -> dict + +def process(name): + key, (addr, size) = find_row(name) + if not key: + errors.append(f"{name}: no filelist row") + return + src = zlib.decompress(rom[addr:addr + size][2:], -15) + D = len(src) + # nsnt is keyed by header symbol (e.g. "armourguard"), not file base + hkey = None + for cand in (key, key[1:], key[:-1], key[1:-1]): + if cand in nsnt: + hkey = cand + break + if not hkey: + errors.append(f"{name}: no header data") + return + NS, NT = nsnt[hkey] + + nodes, R0 = build_nodes(src, D, NS, NT) + if not nodes: + errors.append(f"{name}: empty tree") + return + seq = visit_seq(nodes, src, R0) # GDL packing order + placed = placement_order(nodes, src, R0) # object placement order + + # ---- layout pass (d43_convert verbatim) ---- + regions = [] # (old_off, old_size, new_off) + def add_region(old, osz, cur): + regions.append((old, osz, cur)) + return cur + osz + + dstpos = 0 + # switch table: u64 node VMAs on PC (R0_pc = 8*NS + 12*NT) + for i in range(NS): + dstpos = add_region(4 * i, 8, dstpos) + for i in range(NT): + dstpos = add_region(4 * NS + 12 * i, 12, dstpos) + + node_newoff = {} + rec_newoff = {} + vtx_regions = [] + zero_vtx = [] + for (no, op) in placed: + data = be32o(src, no + 4) + # PC ModelNode is 48 bytes (5 x u64 pointers), not the N64 24 + dstpos = add_region(no, 48, dstpos) + node_newoff[no] = dstpos - 48 + if op not in N64_REC: + errors.append(f"{name}: unknown opcode {op} at {no:#x}") + continue + nsz, psz = N64_REC[op], PC_REC[op] + rec_newoff[data] = dstpos + # allocate the PC footprint (emit writes psz bytes; nsz would let the + # next region start inside this record's struct) + dstpos = add_region(data, psz, dstpos) + if op == 4: + nv = bu16(src, data + 0x10); vo = be32o(src, data + 0xC) + if vo: + sz = 16 * nv if nv else None + if sz is None: + zero_vtx.append((vo,)) + else: + vtx_regions.append((vo, nv)) + if nv: + dstpos = add_region(vo, sz, dstpos) + elif op == 24: + nv = be16(src, data + 0xC); ncv = be16(src, data + 0xE) + vo = be32o(src, data + 8); cvo = be32o(src, data + 0x10) + puo = be32o(src, data + 0x14) + if nv and vo: + vtx_regions.append((vo, nv)); dstpos = add_region(vo, 16 * nv, dstpos) + if ncv and cvo: + op24_is_collision[cvo] = True + vtx_regions.append((cvo, ncv)); dstpos = add_region(cvo, 16 * ncv, dstpos) + if nv and puo: + dstpos = add_region(puo, 2 * nv, dstpos) + elif op == 22: + nv = struct.unpack_from(">i", src, data)[0]; vo = be32o(src, data + 4) + if nv and vo: + vtx_regions.append((vo, nv)); dstpos = add_region(vo, 16 * nv, dstpos) + + # All record-referenced GDLs (needed before zero-vtx/blob layout so the + # "next object" boundaries include them). + ref_gdls = [] + for (no, op) in placed: + data = be32o(src, no + 4) + if op == 4 or op == 24: + for off in (0, 4): + q = be32o(src, data + off) + if q and q not in ref_gdls: + ref_gdls.append(q) + elif op == 22: + q = be32o(src, data + 8) + if q and q not in ref_gdls: + ref_gdls.append(q) + visited_set = set(seq) + unvisited = sorted(g for g in ref_gdls if g not in visited_set) + all_gdls = unvisited + list(seq) # pack order + + obj_offs_now = sorted(set(no for no, _ in placed) | set(rec_newoff.keys()) | + set(v[0] for v in vtx_regions) | set(all_gdls)) + for (vo,) in zero_vtx: + nxt = min((o for o in obj_offs_now if o > vo), default=D) + sz = nxt - vo + if sz <= 0 or sz % 16: + errors.append(f"{name}: zero-vtx array {vo:#x} bad size {sz:#x}") + else: + vtx_regions.append((vo, sz // 16)) + dstpos = add_region(vo, sz, dstpos) + + # Embedded image blobs: G_SETTIMG seg-5 targets (P* logo/legal files only). + # texLoadFromGdl copies G_SETTIMG slots verbatim, so the remapped w1 must + # point at the relocated pixels. Each span runs from its target to the next + # placed object; overlapping spans merge (PlegalpageZ's 5 images share one + # unclaimed span). Bytes are copied raw — the RDP reads them as-is. + blob_targets = [] + for g in all_gdls: + o = g + while o + 8 <= D: + w0 = be32r(src, o); c = w0 >> 24 + if c == 0xB8: + break + if c == 0xFD and (be32r(src, o + 4) >> 24) == 5: + blob_targets.append(be32r(src, o + 4) & MASK) + o += 8 + placed_offs = set(o for o, _, _ in regions) + intervals = [] + for t in sorted(set(blob_targets)): + end = min((o for o in placed_offs if o > t), default=D) + intervals.append([t, end]) + merged = [] + for iv in intervals: + if merged and iv[0] <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], iv[1]) + else: + merged.append(iv) + for (t, e) in merged: + dstpos = add_region(t, e - t, dstpos) + + # GDLs last, tight-packed 16B slots. Visited GDLs go in VISIT order (the + # compaction span arithmetic requires it). Referenced-but-unvisited GDLs + # (e.g. PexplosionbitZ: compaction skips the file entirely when iterate + # visits zero GDLs) are packed just before the visited block so they stay + # outside [off_first, D_PC) and are never span-read; if a runtime BSP + # visible=FALSE state renders them, they are still valid 16B slots. + + gdl_newoff = {} + total_slots = 0 + def pack_gdl(g): + nonlocal dstpos, total_slots + end = gdl_end(src, D, g) + nslots = (end - g) // 8 + if (end - g) % 8: + errors.append(f"{name}: GDL {g:#x} not 8B aligned end") + gdl_newoff[g] = dstpos + dstpos += 16 * nslots + total_slots += nslots + # region size = NEW footprint (16B slots); the old 8B bytes are not a + # 1:1 byte map, so inv_remap must never be called inside a GDL region + # (GDL pointers go through gdl_newoff, not put_ptr). + regions.append((g, 16 * nslots, gdl_newoff[g])) + for g in unvisited: + pack_gdl(g) + for g in seq: + pack_gdl(g) + + D_PC = dstpos + G_PC = min(gdl_newoff.values()) if gdl_newoff else D_PC + g1_n64 = min(seq) if seq else D + + # ---- P_final estimate (D45/D46 marker-expansion worst case) ---- + # Runtime footprint after compaction + texLoadFromGdl expansion: + # P_off = g1_visited_new + 16*S_visited + 16*sum(K_t-1 over visited markers) + # <= D_PC + 16*sum(K_t-1 over ALL referenced-GDL markers) + # K_BOUND: exact worst-case output slots per marker type (tex.c analysis, + # verified over all files by d43_lutscan.py; unknown types default to 44). + # Markers are counted over N64 spans [g, next_gdl_or_EOF) of every + # referenced GDL (superset of what the PC runtime processes: unvisited + # GDLs and inter-GDL junk are never span-read on PC). + K_BOUND = {0: 38, 1: 46, 2: 36, 3: 18, 4: 15} + kslack = 0 + gsorted = sorted(set(all_gdls)) + for i, gg in enumerate(gsorted): + e = gsorted[i + 1] if i + 1 < len(gsorted) else D + j = gg + while j + 8 <= e: + w0 = be32r(src, j) + if (w0 >> 24) == 0xC0: + kslack += K_BOUND.get(w0 & 7, 44) - 1 + j += 8 + P_est = D_PC + 16 * kslack + + # ---- remap machinery ---- + regions.sort() + rold = [x[0] for x in regions] + def remap(off): + if off == 0: + return 0 + i = bisect.bisect_right(rold, off) - 1 + if i < 0: + return None + o, osz, n = regions[i] + if o <= off < o + osz: + return n + (off - o) + return None + + # NOTE: `regions` is sorted by OLD offset above; new offsets follow DFS + # placement order and are NOT sorted, so inv_remap needs its own index. + rsorted_new = sorted(regions, key=lambda x: x[2]) + rnew_offs = [x[2] for x in rsorted_new] + def inv_remap(off): + """PC offset -> N64 offset (for round-trip validation).""" + i = bisect.bisect_right(rnew_offs, off) - 1 + if i < 0: + return None + o, osz, n = rsorted_new[i] + if n <= off < n + osz: + return o + (off - n) + return None + + # ---- emit pass ---- + buf = bytearray(D_PC) + def put_u16(o, v): buf[o:o + 2] = struct.pack(" LE (seg-5 VMA included: + buf[npos + 4:npos + 12] = src[doff + 4:doff + 12] # game skips >= MAX_TEXTURES on both platforms) + if (tid >> 24) == 5 and remap(tid & MASK) is None: + errors.append(f"{name}: texconfig seg-5 target {tid & MASK:#x} not in a blob region") + + # nodes + records + for (no, op) in placed: + npos = node_newoff[no] + put_u16(npos, bu16(src, no)) # Opcode + put_ptr(npos + 8, be32o(src, no + 4)) # Data + put_ptr(npos + 16, be32o(src, no + 8)) # Parent + put_ptr(npos + 24, be32o(src, no + 0xC)) # Next + put_ptr(npos + 32, be32o(src, no + 0x10)) # Prev + put_ptr(npos + 40, be32o(src, no + 0x14)) # Child + + d = be32o(src, no + 4) + r = rec_newoff[d] + if op == 1: # HeaderRecord + put_u16(r, bu16(src, d)); put_s16(r + 2, be16(src, d + 2)) + put_ptr(r + 8, be32o(src, d + 4)) + put_u16(r + 16, bu16(src, d + 8)); put_u16(r + 18, bu16(src, d + 0xA)) + put_u16(r + 20, bu16(src, d + 0xC)) + elif op == 2 or op == 3: # GroupRecord / OP03 + for i in range(3): put_f32(r + 4 * i, d + 4 * i) + put_u16(r + 12, bu16(src, d + 0xC)) + for i in range(3): put_s16(r + 14 + 2 * i, be16(src, d + 0xE + 2 * i)) + put_ptr(r + 24, be32o(src, d + 0x14)) + put_f32(r + 32, d + 0x18) + elif op == 4: # DisplayListRecord + p = be32o(src, d); s = be32o(src, d + 4) + if p: buf[r:r + 4] = struct.pack("> 24) == 5: + n = remap(v & MASK) + if n is None: + errors.append(f"{name}: LinkedTo {v:#x} not in map") + n = 0 + put_u32(q + 8, 0x05000000 | (n & MASK)) + else: + errors.append(f"{name}: LinkedTo {v:#x} neither null nor seg-5") + put_s16(q + 12, be16(src, o + 0xC)); put_s16(q + 14, be16(src, o + 0xE)) + else: + emit_main_vtx(vo, nv, npos) + + # GDLs (pack order: unvisited first, then visit order), 16B LE slots + for g in all_gdls: + end = gdl_end(src, D, g) + npos = gdl_newoff[g] + j = 0 + o = g + while o + 8 <= end: + w0 = be32r(src, o); w1 = be32r(src, o + 4) + q = npos + 16 * j + # PC Gfx slot = {u64 words.w0; u64 words.w1} (Gwords uses uintptr_t). + # Both the game (tex.c: (u8)(words.w0>>24), words.w1 & 0xfff) and + # fast3d (opcode = words.w0>>24 unmasked, seg_addr(words.w1)) read + # N64 w0 from the LOW dword of bytes[0..8) and N64 w1 from the LOW + # dword of bytes[8..16). High dwords must be zero (gSP* macros + # zero-extend their 32-bit patterns into the u64 fields). + c = w0 >> 24 + if c in ADDR_OPS and (w1 >> 24) == 5: + n = remap(w1 & MASK) + if n is None: + errors.append(f"{name}: GDL {g:#x} op{c:#x} w1 {w1:#x} not in map") + n = 0 + w1 = 0x05000000 | (n & MASK) + buf[q:q + 8] = struct.pack(" D_PC: + err(f"tiling gap/overlap at {pos:#x} (region {o:#x}->{n:#x})") + return + pos += osz + if pos != D_PC: + err(f"tiling ends at {pos:#x} != D_PC {D_PC:#x}") + return + + # node fields + for (no, op) in placed: + npos = node_newoff[no] + if bu16(src, no) != struct.unpack_from("> 24) != 5 or (v & MASK) != want: + err(f"node {no:#x}+{f:#x}: {v:#x} != 0x05|{want and hex(want)}") + + # record fields (spot-check every pointer + scalar via inverse remap) + ptrfields = {1: [4], 2: [0x14], 3: [0x14], 8: [8], 9: [0x18, 0x1C], + 12: [0x18], 13: [0x10, 0x14], 18: [0], 24: [8, 0x10, 0x14], + 4: [0xC], 22: [4]} + for (no, op) in placed: + d = be32o(src, no + 4) + r = rec_newoff[d] + for f in ptrfields.get(op, []): + old = be32o(src, d + f) + v = struct.unpack_from("> 24) != 5 or (v & MASK) != want: + err(f"rec{op} {d:#x}+{f:#x}: {v:#x}") + + # GDL slots (all packed GDLs) + for g in all_gdls: + end = gdl_end(src, D, g) + npos = gdl_newoff[g] + j = 0; o = g + while o + 8 <= end: + w0 = be32r(src, o); w1 = be32r(src, o + 4) + q = npos + 16 * j + if struct.unpack_from("> 24 + if struct.unpack_from("> 24) == 5: + want = remap(w1 & MASK) + if w1p != (0x05000000 | want): + err(f"gdl {g:#x} slot {j}: w1 {w1p:#x} != 0x05|{hex(want)}") + elif w1p != w1: + err(f"gdl {g:#x} slot {j}: w1 passthrough mismatch") + if struct.unpack_from("6} D={r['D']:>6} round8(C)+8={round8(r['C'])+8:#7x} " + f"D+r8C={r['D']+round8(r['C']):#7x} P_est={r['Pest']:>#7x} vs {b:#x}") + +# gun.c used_to_load_1st_person_model_on_demand: every item model except +# ITEM_SUIT_LF_HAND (Csuit_lf_handZ, 0x18000) and ITEM_TRIGGER/ITEM_WATCHLASER +# (GtriggerZ/GwatchlaserZ, 0x17000 — both in DIRECT above) loads into the +# D_80032464 weapon buffer. G* files are exactly the item models. +GUN_BUF_FILES = [n for n in results + if n.startswith("G") and n.endswith("Z") + and n not in ("GtriggerZ", "GwatchlaserZ")] +worst_w = None +for n in GUN_BUF_FILES: + r = results[n] + if worst_w is None or r["Pest"] > worst_w[0]: + worst_w = (r["Pest"], n, r) +chk(worst_w[0] <= WEAPON_BUF and worst_w[2]["D"] + round8(worst_w[2]["C"]) <= WEAPON_BUF + and round8(worst_w[2]["C"]) + 8 <= WEAPON_BUF, + f"worst gun-buffer item {worst_w[1]}: P_est={worst_w[0]:#x}, D+r8C={worst_w[2]['D']+round8(worst_w[2]['C']):#x} " + f"r8C+8={round8(worst_w[2]['C'])+8:#x} vs {WEAPON_BUF:#x}") + +def chain_check(label, slots, buf_pc): + """Chain fit: the game advances bufferPtr by each file's post-load + poolRemaining (>= its actual footprint P_off), so the chain works iff + sum(P_off_i) <= R_pc (prefix argument: A_j >= P_off_j) + with P_off_i bounded by P_est_i (D45/D46 marker-expansion worst case). + Slots are independent (any body+head+weapon can be drawn), so the worst + combination is the sum of per-slot maxima. Each slot's DMA/decompress + constraints are checked against a lower bound on its remaining buffer. + """ + per_slot = [] + for sl, cands in slots: + best = None + for n in cands: + r = results.get(n) + if not r: + continue + if best is None or r["Pest"] > best[0]: + best = (r["Pest"], n, r) + if best is None: + chk(False, f"{label}/{sl}: no candidates"); continue + per_slot.append((sl, best[1], best[2])) + total = sum(r["Pest"] for _, _, r in per_slot) + chk(total <= buf_pc, + f"{label}: worst combination {total:#x} vs buffer {buf_pc:#x} " + + " ".join(f"{sl}:{n}(P_est={r['Pest']:#x})" for sl, n, r in per_slot)) + rem = buf_pc + for sl, n, r in per_slot: + ok = round8(r["C"]) + 8 <= rem and r["D"] + round8(r["C"]) <= rem + chk(ok, f"{label}/{sl}: worst {n} C={r['C']:#x} D={r['D']:#x} " + f"round8(C)+8={round8(r['C'])+8:#x} D+r8C={r['D']+round8(r['C']):#x} " + f"vs rem-lower={rem:#x}") + rem -= round64(r["Pest"]) + +# candidate sets +heads = [n for n in results if n.startswith("Chead")] +bodies = [n for n in results if n.startswith("C") and n.endswith("Z") + and not n.startswith("Chead") and n != "Csuit_lf_handZ"] +# cast screen (front.c random_rifles_in_intro / random_pistols_in_intro): +CAST_WEAPONS = ["PchrkalashZ", "Pchrm16Z", "Pchrfnp90Z", "PchrautoshotZ", + "PchrgrenadelaunchZ", "PchrsniperrifleZ", + "PchrwppkZ", "PchrwppksilZ", "PchrskorpionZ", "PchruziZ", + "Pchrtt33Z", "PchrrugerZ", "PchrlaserZ", "PchrgoldenZ"] +# bondview2 held prop (player.c getPropForHeldItem): +BONDVIEW_PROPS = ["PchrknifeZ", "PchrwppkZ", "PchrskorpionZ", "PchrkalashZ", + "PchruziZ", "Pchrmp5kZ", "PchrspectreZ", "Pchrm16Z", + "Pchrfnp90Z", "PchrshotgunZ", "PchrgrenadelaunchZ", + "PchrgrenadeZ", "PchrremotemineZ", "PchrproximitymineZ", + "PchrtimedmineZ", "PchrrugerZ", "PchrlaserZ", + "PchrsniperrifleZ", "Pchrmp5ksilZ", "Pchrtt33Z", + "PchrwppksilZ", "PchrthrowknifeZ", "PchrautoshotZ", + "PchrrocketlaunchZ", "PchrgoldenZ", "PflagZ"] +for n in CAST_WEAPONS + BONDVIEW_PROPS: + chk(n in results, f"candidate present: {n}") + +print("\n== cast screen chain (front.c:7811/7819/7869; PC 0x25000, N64 0x18160) ==") +chain_check("cast", [("body", bodies), ("head", heads), ("weapon", CAST_WEAPONS)], + 0x25000) + +print("\n== title gunbarrel chain (title.c:489/495/517; avail 0x24D00 both sides) ==") +# fixed files (c_item_entries indexed by BODIES enum; BODY_Male_Karl=HEAD_START +# is an alias so the effective count is 80, matching the 80 record includes): +# BODY_Brosnan_Tuxedo=5 -> CdjbondZ +# BODY_Male_Pierce_Bond_Tuxedo=78 -> CheadbrosnanZ (used as the head slot) +# PitemZ_entries[PROP_CHRWPPK] -> PchrwppkZ +t1 = ["CdjbondZ"] +t2 = ["CheadbrosnanZ"] +t3 = ["PchrwppkZ"] +chain_check("title", [("brosnan", t1), ("bond", t2), ("wppk", t3)], + 0x24D00) + +print("\n== bondview2 chain (bondview2.c:490/502/571; PC 0x23000, N64 0x14820) ==") +chain_check("bondview", [("body", bodies), ("head", heads), ("prop", BONDVIEW_PROPS)], + 0x23000) + +print("\n== stage bank (dst==0 fresh loads) ==") +maxr = max(round8(r["C"]) + 8 for r in results.values()) +chk(maxr <= 0x24C400, f"max round8(C)+8 = {maxr:#x} vs STAGE bank 0x24C400") + +# N64 sanity: same overlap check with N64 sizes (the game worked on N64) +print("\n== N64 sanity (overlap model) ==") +n64_fail = 0 +for name, r in results.items(): + # recompute C_n64 cheaply? we only stored Dn64; approximate with stored comp? no. + pass +# (N64 compressed sizes: recomputed below from ROM for the DIRECT set only) +for name, b in DIRECT.items(): + key, (addr, size) = find_row(name) + Cn = size - 2 + Dn = results[name]["Dn64"] + ok = round8(Cn) + 8 <= b and Dn + round8(Cn) <= b + if not ok: + n64_fail += 1 + print(f" N64 {name}: round8(C)+8={round8(Cn)+8:#x} D+r8C={Dn+round8(Cn):#x} vs {b:#x}") +print(f"N64 overlap-model violations: {n64_fail} (0 expected; if >0 the overlap " + f"constraint is looser than modeled — review before trusting PC checks)") + +# ---------------------------------------------------------------- output --- +if CHECK_ONLY: + print("\ncheck-only: no sidecars written") +else: + os.makedirs(OUT_DIR, exist_ok=True) + manifest = [] # (name, offset, size) in table order + off = 0 + chunks = [] + for name in model_names: + r = results[name] + start = (off + 15) & ~15 + if start > off: + chunks.append(b"\x00" * (start - off)) + off = start + manifest.append((name, off, r["C"])) + chunks.append(r["comp"]) + off += r["C"] + total = (off + 15) & ~15 + if off % 16: + chunks.append(b"\x00" * (16 - (off % 16))) + with open(os.path.join(OUT_DIR, "pcmodels.bin"), "wb") as f: + for c in chunks: + f.write(c) + # decimal fields: the C parser uses strtol(..., 10) + with open(os.path.join(OUT_DIR, "manifest.csv"), "w", newline="") as f: + w = csv.writer(f) + w.writerow(["name", "offset", "size"]) + for name, o, s in manifest: + w.writerow([name, o, s]) + print(f"\nwrote {len(manifest)} sidecars to {OUT_DIR}/pcmodels.bin") + print(f"total reservation needed: {total:#x} bytes " + f"(romSize + this must fit the extended VirtualAlloc)") + +if fail: + print(f"\n{fail} CHECK FAILURES") + sys.exit(1) +print("\nALL CHECKS PASSED") diff --git a/tools_pc/d43_layoutprobe.c b/tools_pc/d43_layoutprobe.c new file mode 100644 index 00000000..6f06e7d3 --- /dev/null +++ b/tools_pc/d43_layoutprobe.c @@ -0,0 +1,112 @@ +/* D43 Plan B: compiler-verified PC layout probe for model-file records. + * + * Prints sizeof/offsetof for every struct the offline emit pass (d43_emit.py) + * must reproduce byte-for-byte, compiled with the SAME toolchain + includes as + * the game build (x86_64-w64-mingw32-gcc, port/shim first). Run: + * x86_64-w64-mingw32-gcc -DPORT -Iport/shim -I. -Iinclude -Iinclude/PR \ + * -Isrc -Isrc/game -Isrc/libultra -Isrc/libultra/audio -Iport/include \ + * tools_pc/d43_layoutprobe.c -o build-pc/d43_layoutprobe.exe + */ +#include +#include +#include + +/* The decomp's include/stddef.h is an empty guard (no offsetof); use the GCC + * builtin so this probe works in a C TU with the project include chain. */ +#ifndef offsetof +#define offsetof(type, member) __builtin_offsetof(type, member) +#endif + +#define P_SZ(t) printf("SZ %-40s %lu\n", #t, (unsigned long)sizeof(t)) +#define P_OFF(t, f) printf("OFF %-32s %-12s %lu\n", #t, #f, (unsigned long)offsetof(t, f)) + +int main(void) +{ + P_SZ(ModelNode); + P_OFF(ModelNode, Opcode); P_OFF(ModelNode, Data); P_OFF(ModelNode, Parent); + P_OFF(ModelNode, Next); P_OFF(ModelNode, Prev); P_OFF(ModelNode, Child); + + P_SZ(Vertex); + P_OFF(Vertex, coord); P_OFF(Vertex, index); P_OFF(Vertex, s); + P_OFF(Vertex, CollisionRelatedIndex); P_OFF(Vertex, CollisionReserved); + + P_SZ(ModelFileHeader); + P_OFF(ModelFileHeader, RootNode); P_OFF(ModelFileHeader, Switches); + P_OFF(ModelFileHeader, numSwitches); P_OFF(ModelFileHeader, numtextures); + P_OFF(ModelFileHeader, Textures); + + P_SZ(ModelFileTextures); + P_OFF(ModelFileTextures, TextureID); P_OFF(ModelFileTextures, Width); + P_OFF(ModelFileTextures, RenderDepth); P_OFF(ModelFileTextures, sflags); + + P_SZ(ModelRoData_HeaderRecord); + P_OFF(ModelRoData_HeaderRecord, AnimPart); P_OFF(ModelRoData_HeaderRecord, MatrixIndex); + P_OFF(ModelRoData_HeaderRecord, FirstGroup); P_OFF(ModelRoData_HeaderRecord, Group1); + P_OFF(ModelRoData_HeaderRecord, RwDataIndex); + + P_SZ(ModelRoData_GroupRecord); + P_OFF(ModelRoData_GroupRecord, Origin); P_OFF(ModelRoData_GroupRecord, JointID); + P_OFF(ModelRoData_GroupRecord, MatrixID0); P_OFF(ModelRoData_GroupRecord, ChildGroup); + P_OFF(ModelRoData_GroupRecord, BoundingVolumeRadius); + + P_SZ(ModelRoData_DisplayListRecord); + P_OFF(ModelRoData_DisplayListRecord, Primary); P_OFF(ModelRoData_DisplayListRecord, Secondary); + P_OFF(ModelRoData_DisplayListRecord, BaseAddr); P_OFF(ModelRoData_DisplayListRecord, Vertices); + P_OFF(ModelRoData_DisplayListRecord, numVertices); P_OFF(ModelRoData_DisplayListRecord, ModelType); + + P_SZ(ModelRoData_LODRecord); + P_OFF(ModelRoData_LODRecord, MinDistance); P_OFF(ModelRoData_LODRecord, MaxDistance); + P_OFF(ModelRoData_LODRecord, Affects); P_OFF(ModelRoData_LODRecord, RwDataIndex); + + P_SZ(ModelRoData_BSPRecord); + P_OFF(ModelRoData_BSPRecord, Point); P_OFF(ModelRoData_BSPRecord, Vector); + P_OFF(ModelRoData_BSPRecord, leftChild); P_OFF(ModelRoData_BSPRecord, rightChild); + P_OFF(ModelRoData_BSPRecord, RwDataIndex); + + P_SZ(ModelRoData_BoundingBoxRecord); + P_OFF(ModelRoData_BoundingBoxRecord, ModelNumber); P_OFF(ModelRoData_BoundingBoxRecord, Bounds); + + P_SZ(ModelRoData_GunfireRecord); + P_OFF(ModelRoData_GunfireRecord, Offset); P_OFF(ModelRoData_GunfireRecord, Size); + P_OFF(ModelRoData_GunfireRecord, Image); P_OFF(ModelRoData_GunfireRecord, Scale); + P_OFF(ModelRoData_GunfireRecord, RwDataIndex); + + P_SZ(ModelRoData_ShadowRecord); + P_OFF(ModelRoData_ShadowRecord, pos); P_OFF(ModelRoData_ShadowRecord, size); + P_OFF(ModelRoData_ShadowRecord, image); P_OFF(ModelRoData_ShadowRecord, Header); + P_OFF(ModelRoData_ShadowRecord, Scale); + + P_SZ(ModelRoData_InterlinkageRecord); + P_OFF(ModelRoData_InterlinkageRecord, pos); P_OFF(ModelRoData_InterlinkageRecord, pos2); + P_OFF(ModelRoData_InterlinkageRecord, Scale); + + P_SZ(ModelRoData_SwitchRecord); + P_OFF(ModelRoData_SwitchRecord, Controls); P_OFF(ModelRoData_SwitchRecord, RwDataIndex); + + P_SZ(ModelRoData_GroupSimpleRecord); + P_OFF(ModelRoData_GroupSimpleRecord, Origin); P_OFF(ModelRoData_GroupSimpleRecord, Group1); + P_OFF(ModelRoData_GroupSimpleRecord, Group2); P_OFF(ModelRoData_GroupSimpleRecord, BoundingVolumeRadius); + + P_SZ(ModelRoData_DisplayListPrimaryRecord); + P_OFF(ModelRoData_DisplayListPrimaryRecord, numVertices); + P_OFF(ModelRoData_DisplayListPrimaryRecord, Vertices); + P_OFF(ModelRoData_DisplayListPrimaryRecord, Primary); + P_OFF(ModelRoData_DisplayListPrimaryRecord, BaseAddr); + + P_SZ(ModelRoData_HeadPlaceholderRecord); + P_OFF(ModelRoData_HeadPlaceholderRecord, RwDataIndex); + + P_SZ(ModelRoData_DisplayList_CollisionRecord); + P_OFF(ModelRoData_DisplayList_CollisionRecord, Primary); + P_OFF(ModelRoData_DisplayList_CollisionRecord, Secondary); + P_OFF(ModelRoData_DisplayList_CollisionRecord, Vertices); + P_OFF(ModelRoData_DisplayList_CollisionRecord, numVertices); + P_OFF(ModelRoData_DisplayList_CollisionRecord, numCollisionVertices); + P_OFF(ModelRoData_DisplayList_CollisionRecord, CollisionVertices); + P_OFF(ModelRoData_DisplayList_CollisionRecord, PointUsage); + P_OFF(ModelRoData_DisplayList_CollisionRecord, ModelType); + P_OFF(ModelRoData_DisplayList_CollisionRecord, RwDataIndex); + P_OFF(ModelRoData_DisplayList_CollisionRecord, BaseAddr); + + return 0; +}