From 323558d44e10c6aa970e023461c1f2a0805b53d5 Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:54:56 +0100 Subject: [PATCH 1/3] Import RNC-packed N64 code and verify cartridge reconstruction --- README.md | 16 +++ src/decompnet/consoles/n64/code.py | 17 ++- src/decompnet/consoles/n64/rnc.py | 184 +++++++++++++++++++++++++++++ src/decompnet/rom/export.py | 33 ++++++ src/decompnet/rom/export_build.py | 14 ++- tests/test_export.py | 8 ++ tests/test_rnc.py | 135 +++++++++++++++++++++ 7 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 src/decompnet/consoles/n64/rnc.py create mode 100644 tests/test_rnc.py diff --git a/README.md b/README.md index 88b10c32..a4cb4350 100644 --- a/README.md +++ b/README.md @@ -319,3 +319,19 @@ The code that came from decomp.wtf was released under the MIT license by Sam Wol [LICENSE-decomp.wtf](LICENSE-decomp.wtf).
Not related to Nintendo, Sony or Microsoft. Platform names are trademarks of their owners.
+ +### RNC-packed N64 programs + +The N64 importer recognizes RNC method-1 blocks, verifies both CRC16 checksums, and checks the decoded bytes for +code before adding them to the analysis image. Method 2 and encrypted/locked streams are not supported. A small +initial function count can still mean that the game uses another packing format or an undiscovered loader. + +For RNC-expanded exports, `baserom.z64` is the expanded analysis image and `baserom.cartridge.z64` is the original +normalized cartridge. Keep both private. The build first checks the linked analysis image, then reconstructs the +packed blocks using literals from the newly linked output and the original stream's coding decisions. It rejects +changes incompatible with those references and checks the complete cartridge against the original. This template +operation supports matching builds; it is not a general compressor for mods. Other expanded packing formats fail +export with an explanation until they have a verified cartridge reconstruction. + +An identical rebuild can still contain assembly and binary fallbacks. Check the reported number of functions +selected as C separately; an identical cartridge does not mean that every function has been decompiled. diff --git a/src/decompnet/consoles/n64/code.py b/src/decompnet/consoles/n64/code.py index 584d2737..a778a94f 100644 --- a/src/decompnet/consoles/n64/code.py +++ b/src/decompnet/consoles/n64/code.py @@ -214,6 +214,21 @@ def unpack_code(rom: bytes) -> list[tuple[int, bytes]]: if len(out) == size and _looks_like_code(out): found.append((at, out)) at = rom.find(b"Yaz0", at + 4) + from . import rnc + + at = rom.find(b"RNC\x01") + while at >= 0: + size = int.from_bytes(rom[at + 4:at + 8], "big") + packed = int.from_bytes(rom[at + 8:at + 12], "big") + if 0x1000 <= size <= rnc.MAX_OUTPUT and at + rnc.HEADER_BYTES + packed <= len(rom): + try: + out = rnc.unpack(rom[at:at + rnc.HEADER_BYTES + packed]) + except rnc.RncError: + pass + else: + if _looks_like_code(out): + found.append((at, out)) + at = rom.find(b"RNC\x01", at + 4) return sorted(found) @@ -221,7 +236,7 @@ def unpack_code(rom: bytes) -> list[tuple[int, bytes]]: def add_segments(config: Path, workdir: Path, image: str, log=print) -> str: """An N64 game's code outside the main segment (n64code.py), added to splat's config as code segments of their - own. Compressed code (Rare's, Yaz0) is unpacked first and appended to a working copy of the ROM, which splat then + own. Compressed code (Rare's, Yaz0, RNC method 1) is unpacked first and appended to a working copy of the ROM, which splat then reads instead (and an export rebuilds). Returns the image splat reads.""" import hashlib diff --git a/src/decompnet/consoles/n64/rnc.py b/src/decompnet/consoles/n64/rnc.py new file mode 100644 index 00000000..0bbc45c0 --- /dev/null +++ b/src/decompnet/consoles/n64/rnc.py @@ -0,0 +1,184 @@ +"""RNC method 1 containers: bounded Huffman/LZ unpacking of code held in a cartridge image. + +Control bits are little-endian words interleaved with literal bytes. The reader must discard prefetched literal +bytes after each run, keeping only the unused control bits. Both container checksums are checked before code is used. +""" + +from __future__ import annotations + +import functools +import struct +from dataclasses import dataclass + +HEADER_BYTES = 18 +MAX_OUTPUT = 4 << 20 +MAX_CODE_BITS = 16 + + +class RncError(ValueError): + pass + + +@functools.cache +def _crc_table() -> tuple[int, ...]: + table = [] + for value in range(256): + for _ in range(8): + value = (value >> 1) ^ (0xA001 if value & 1 else 0) + table.append(value) + return tuple(table) + + +def crc16(data: bytes) -> int: + value = 0 + table = _crc_table() + for byte in data: + value = (value >> 8) ^ table[(value ^ byte) & 0xFF] + return value + + +@dataclass +class Literal: + packed: int + output: int + size: int + + +class _Bits: + def __init__(self, payload: bytes): + self.data = payload + b"\0\0" + self.end = len(payload) + self.pos = 0 + self.buffer = self.word(0) + self.left = 0 + + def word(self, pos: int) -> int: + if pos + 2 > len(self.data): + raise RncError("truncated RNC control bits") + return self.data[pos] | self.data[pos + 1] << 8 + + def take(self, count: int) -> int: + if not 0 <= count <= 16: + raise RncError("invalid RNC bit count") + if not count: + return 0 + value = self.buffer & ((1 << count) - 1) + if count > self.left: + self.buffer >>= self.left + self.pos += 2 + self.buffer |= self.word(self.pos) << 16 + count -= self.left + self.left = 16 + self.buffer >>= count + self.left -= count + return value + + def literal(self, count: int) -> bytes: + if count < 0 or self.pos + count > self.end: + raise RncError("truncated RNC literal run") + result = self.data[self.pos:self.pos + count] + self.pos += count + self.buffer &= (1 << self.left) - 1 + self.buffer |= self.word(self.pos) << self.left + return result + + +def _table(bits: _Bits) -> list[tuple[int, int, int, int]]: + lengths = [bits.take(4) for _ in range(bits.take(5))] + entries = [] + code = 0 + for width in range(1, MAX_CODE_BITS + 1): + for symbol, length in enumerate(lengths): + if length != width: + continue + if code >= 1 << width: + raise RncError("oversubscribed RNC Huffman tree") + reversed_code = int(f"{code:0{width}b}"[::-1], 2) + entries.append(((1 << width) - 1, reversed_code, width, symbol)) + code += 1 + code <<= 1 + return entries + + +def _value(bits: _Bits, table: list[tuple[int, int, int, int]]) -> int: + for mask, code, width, symbol in table: + if bits.buffer & mask == code: + bits.take(width) + return symbol if symbol < 2 else (1 << (symbol - 1)) | bits.take(symbol - 1) + raise RncError("RNC stream has no matching Huffman code") + + +def unpack(data: bytes, *, max_output: int = MAX_OUTPUT, literals: list[Literal] | None = None) -> bytes: + """Unpack a checksummed method-1 block. Optional literal locations support a verified compression template.""" + if len(data) < HEADER_BYTES or data[:4] != b"RNC\x01": + raise RncError("not an RNC method-1 container") + size, packed, unpacked_crc, packed_crc = struct.unpack_from(">IIHH", data, 4) + if not 0 < size <= max_output or packed < 2 or HEADER_BYTES + packed > len(data): + raise RncError("invalid RNC sizes") + payload = data[HEADER_BYTES:HEADER_BYTES + packed] + if crc16(payload) != packed_crc: + raise RncError("RNC packed checksum differs") + bits = _Bits(payload) + if bits.take(2): + raise RncError("encrypted or locked RNC streams are unsupported") + out = bytearray() + chunks = 0 + while len(out) < size: + raw, distance, length = _table(bits), _table(bits), _table(bits) + count = bits.take(16) + if not count: + raise RncError("empty RNC chunk") + for index in range(count): + n = _value(bits, raw) + if len(out) + n > size: + raise RncError("RNC literal run exceeds declared output") + if n: + if literals is not None: + literals.append(Literal(HEADER_BYTES + bits.pos, len(out), n)) + out += bits.literal(n) + if index + 1 < count: + offset, n = _value(bits, distance) + 1, _value(bits, length) + 2 + if offset > len(out) or len(out) + n > size: + raise RncError("RNC back-reference is outside its output") + for _ in range(n): + out.append(out[-offset]) # a run can overlap its own already-decoded bytes + chunks += 1 + if chunks > 255: + raise RncError("too many RNC chunks") + if crc16(out) != unpacked_crc: + raise RncError("RNC unpacked checksum differs") + return bytes(out) + + +def repack_like(output: bytes, template: bytes) -> bytes: + """Reuse a valid stream's coding decisions, deriving its literal bytes from newly built output. + + This is a matching-build operation, not a general compressor. Edits incompatible with the template's LZ + references are rejected by decoding the result and comparing every output byte. + """ + locations: list[Literal] = [] + original = unpack(template, literals=locations) + if len(output) != len(original): + raise RncError("RNC template requires the original output size") + packed = int.from_bytes(template[8:12], "big") + result = bytearray(template[:HEADER_BYTES + packed]) + for span in locations: + result[span.packed:span.packed + span.size] = output[span.output:span.output + span.size] + struct.pack_into(">HH", result, 12, crc16(output), crc16(result[HEADER_BYTES:])) + if unpack(result) != output: + raise RncError("new output is incompatible with RNC template references") + return bytes(result) + + +def rebuild_cartridge(analysis: bytes, original: bytes, blocks: list[dict]) -> bytes: + """Replace packed code with literals derived from a linked analysis image; preserve rebuilt prefix bytes.""" + if len(analysis) < len(original): + raise RncError("analysis image is shorter than the cartridge") + result = bytearray(analysis[:len(original)]) + for block in blocks: + offset, start, size = block['rom'], block['analysis'], block['size'] + if not 0 <= offset <= len(original) - HEADER_BYTES or not len(original) <= start <= len(analysis) - size: + raise RncError("compressed-block mapping is outside its images") + packed = repack_like(analysis[start:start + size], original[offset:]) + result[offset:offset + len(packed)] = packed + return bytes(result) diff --git a/src/decompnet/rom/export.py b/src/decompnet/rom/export.py index f689b9a7..55e756eb 100644 --- a/src/decompnet/rom/export.py +++ b/src/decompnet/rom/export.py @@ -229,6 +229,8 @@ def export_rom(net, slug: str, out: Path, console, log=print) -> dict: for seg in segments: kind = seg.get("type") name = re.sub(r"\W", "_", str(seg.get("name") or f"{seg['start']:X}")) + if name[0].isdigit(): + name = "seg_" + name if kind == "header" or (kind == "hasm" and "vram" not in seg): layout.append({"name": name, "kind": "raw", "start": seg["start"], "objects": [f"build/asm/{seg.get('name', 'header')}.o"]}) elif kind == "bin": @@ -281,6 +283,25 @@ def export_rom(net, slug: str, out: Path, console, log=print) -> dict: "assembly": objects, "segments": layout, "functions": fn_json, "symbols": {k: v for k, v in sorted(absolute.items(), key=lambda kv: kv[1])}, } + # An expanded analysis image is not the cartridge. Rebuild RNC literals from linked code before reporting + # an original-ROM match; both the expanded image and the final cartridge must pass their comparisons. + original_name = cfg["rom"].get("program") + if console.id == "n64" and original_name and original_name != image_name: + from ..consoles.n64.code import unpack_code + + original = (work / original_name).read_bytes() + blocks, cursor = [], len(original) + for offset, payload in unpack_code(original): + cursor += -cursor % 0x10 + 0x2000 + if original[offset:offset + 4] != b"RNC\x01" or image[cursor:cursor + len(payload)] != payload: + raise ExportError("compressed analysis export needs a verified reconstruction for every block") + blocks.append({"rom": offset, "analysis": cursor, "size": len(payload)}) + cursor += len(payload) + if not blocks: + raise ExportError("expanded analysis image has no verified compressed-block reconstruction") + cartridge = f"baserom.cartridge{ext}" + (out / cartridge).write_bytes(original) + build["cartridge"] = {"image": cartridge, "sha1": hashlib.sha1(original).hexdigest(), "blocks": blocks} (out / "build.json").write_text(json.dumps(build, indent=1), encoding="utf-8") shutil.copyfile(Path(__file__).with_name("export_build.py"), out / "build.py") units_json = [] @@ -294,6 +315,18 @@ def export_rom(net, slug: str, out: Path, console, log=print) -> dict: encoding="utf-8") matched = sum(1 for f in functions if f["kind"] == "c") _write_docs(out, proj["name"], console.name, base, matched, len(functions), as_fallback) + if cartridge := build.get("cartridge"): + readme = out / "README.md" + text = readme.read_text(encoding="utf-8") + text = text.replace(f"1. Put your own copy of the game here as `{base}` (it is in `.gitignore`; never commit it).", + f"1. Keep both generated private inputs: `{base}` (expanded analysis image) and " + f"`{cartridge['image']}` (original normalized cartridge). Reimport and export your ROM " + "to regenerate them; the original cartridge alone is not the expanded analysis input.") + text += ("\nThe build first compares the expanded linked image, then reconstructs RNC method-1 blocks " + "from that linked output using the original compression template. It checks the final cartridge " + "against its fixed original checksum. Incompatible edits require a general compressor.\n") + readme.write_text(text, encoding="utf-8") + # everything made from the game itself stays local: build.py regenerates it from your copy (out / ".gitignore").write_text("build/\nbaserom*\nasm/\nassets/\n*.ld\n*_auto.txt\n.splat/\n*.d\n", encoding="utf-8") diff --git a/src/decompnet/rom/export_build.py b/src/decompnet/rom/export_build.py index 056dcff2..d3b1656e 100644 --- a/src/decompnet/rom/export_build.py +++ b/src/decompnet/rom/export_build.py @@ -292,7 +292,19 @@ def build() -> bool: out, base = (ROOT / B["output"]).read_bytes(), (ROOT / B["image"]).read_bytes() c_count = sum(1 for fn in B["functions"] if c_source(fn)) if out == base: - print(f"identical: {B['output']} matches {B['image']} byte for byte " + if cartridge := B.get("cartridge"): + from decompnet.consoles.n64.rnc import rebuild_cartridge + + original = (ROOT / cartridge["image"]).read_bytes() + if hashlib.sha1(original).hexdigest() != cartridge["sha1"]: + raise ValueError("original cartridge checksum differs from the fixed export target") + out = rebuild_cartridge(out, original, cartridge["blocks"]) + (ROOT / B["output"]).write_bytes(out) + if out != original: + print("differs: reconstructed cartridge does not match the original") + return False + print(f"cartridge verified: {cartridge['sha1']}") + print(f"identical: {B['output']} matches {B.get('cartridge', {}).get('image', B['image'])} byte for byte " f"({c_count} of {len(B['functions'])} functions in C)") return True at = next((i for i in range(min(len(out), len(base))) if out[i] != base[i]), min(len(out), len(base))) diff --git a/tests/test_export.py b/tests/test_export.py index d1af244c..9b5032d6 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest +import yaml from elftools.elf.elffile import ELFFile from decompnet import toolchains @@ -76,6 +77,13 @@ def test_export_rebuilds_the_game(tmp_path, platform): if not _ready(platform): pytest.skip(f"needs the {platform} toolchain and binutils-mips") net, slug = _decompile(tmp_path, platform) + if platform == "n64": + # Unnamed cartridge data segments receive hexadecimal names that can start with a digit. The generated + # linker symbols must still parse; exercise the same case on the homemade demo's raw header segment. + config = next(Path(net._project(slug)["root"]).glob("*.yaml")) + contents = yaml.safe_load(config.read_text()) + contents["segments"][0]["name"] = "123" + config.write_text(yaml.safe_dump(contents, sort_keys=False)) out = net.export_rom(slug, log=lambda *_: None) assert out["identical"], out["log"] assert out["matched"] == len(FUNCTIONS) and not out["fallback"] diff --git a/tests/test_rnc.py b/tests/test_rnc.py new file mode 100644 index 00000000..91839a64 --- /dev/null +++ b/tests/test_rnc.py @@ -0,0 +1,135 @@ +"""Synthetic containers only: no cartridge bytes or extracted game code.""" +import struct + +import pytest + +from decompnet.consoles.n64 import rnc + + +class Writer: + def __init__(self): + self.data = bytearray() + self.bit = 16 + self.word = 0 + + def put(self, value, count): + for bit in range(count): + if self.bit == 16: + self.word = len(self.data) + self.data += b'\0\0' + self.bit = 0 + self.data[self.word + self.bit // 8] |= ((value >> bit) & 1) << (self.bit % 8) + self.bit += 1 + + def tree(self, lengths): + self.put(len(lengths), 5) + for length in lengths: + self.put(length, 4) + + def container(self, output): + payload = bytes(self.data) + return (b'RNC\x01' + struct.pack('>IIHHBB', len(output), len(payload), + rnc.crc16(output), rnc.crc16(payload), 0, 1) + payload) + + +def literal_block(output): + n = len(output) + symbol = n.bit_length() if n >= 2 else n + w = Writer() + w.put(0, 2) + w.tree([0] * symbol + [1]) + w.tree([]) + w.tree([]) + w.put(1, 16) + w.put(0, 1) + if symbol >= 2: + w.put(n - (1 << (symbol - 1)), symbol - 1) + w.data += output + return w.container(output) + + +@pytest.mark.parametrize('size', [1, 2, 3, 17, 256, 4096]) +def test_literals(size): + output = bytes(i % 251 for i in range(size)) + locations = [] + block = literal_block(output) + assert rnc.unpack(block, literals=locations) == output + assert len(locations) == 1 + span = locations[0] + assert block[span.packed:span.packed + span.size] == output + assert span.output == 0 + + +def overlap_block(): + w = Writer() + w.put(0, 2) + w.tree([1, 0, 1]) + w.tree([0, 0, 1]) + w.tree([0, 0, 0, 1]) + w.put(2, 16) + w.put(1, 1) # literal symbol 2 + w.put(1, 1) # literal length 3 + w.data += b'abc' + w.put(0, 1) # distance symbol 2 + w.put(0, 1) # distance 2 + 1 + w.put(0, 1) # length symbol 3 + w.put(0, 2) # length 4 + 2 + w.put(0, 1) # final zero-length literal + return w.container(b'abcabcabc') + + +def test_overlapping_backreference(): + assert rnc.unpack(overlap_block()) == b'abcabcabc' + assert rnc.unpack(rnc.repack_like(b'xyzxyzxyz', overlap_block())) == b'xyzxyzxyz' + with pytest.raises(rnc.RncError): + rnc.repack_like(b'abcabcabd', overlap_block()) + + +def test_rejects_corruption_and_limits(): + block = literal_block(b'hello world') + with pytest.raises(rnc.RncError, match='sizes'): + rnc.unpack(block, max_output=3) + with pytest.raises(rnc.RncError, match='sizes'): + rnc.unpack(block[:-1]) + damaged = bytearray(block) + damaged[-1] ^= 1 + with pytest.raises(rnc.RncError, match='packed checksum'): + rnc.unpack(damaged) + damaged = bytearray(block) + damaged[12] ^= 1 + with pytest.raises(rnc.RncError, match='unpacked checksum'): + rnc.unpack(damaged) + + +def test_template_uses_new_literals(): + original = literal_block(b'hello world') + rebuilt = rnc.repack_like(b'other words', original) + assert rebuilt != original + assert rnc.unpack(rebuilt) == b'other words' + assert rnc.repack_like(b'hello world', original) == original + with pytest.raises(rnc.RncError, match='size'): + rnc.repack_like(b'short', original) + + +def test_import_discovers_code_and_rejects_assets(): + from decompnet.consoles.n64.code import unpack_code + + # Invented MIPS stubs, with enough prologues and returns to exercise code detection. + stub = bytes.fromhex('27bdfff0 afbf000c 8fbf000c 27bd0010 03e00008 00000000') + code = stub * 256 + asset = literal_block(b'A' * 4096) + packed = literal_block(code) + image = bytes(32) + asset + bytes(32) + packed + assert unpack_code(image) == [(32 + len(asset) + 32, code)] + + +def test_rebuild_uses_linked_payload_and_preserves_prefix_changes(): + block = literal_block(b'hello world') + original = b'HEAD' + block + analysis = original + b'other words' + mapping = [{'rom': 4, 'analysis': len(original), 'size': 11}] + rebuilt = rnc.rebuild_cartridge(analysis, original, mapping) + assert rnc.unpack(rebuilt[4:]) == b'other words' + changed = b'EDIT' + analysis[4:] + assert rnc.rebuild_cartridge(changed, original, mapping)[:4] == b'EDIT' + assert rnc.rebuild_cartridge(original + b'hello world', original, mapping) == original From 0beac5fd6219710d10599094f9a0881cacb265ae Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:00:10 +0100 Subject: [PATCH 2/3] Resolve address-bearing references across exported objects --- src/decompnet/rom/export_build.py | 18 ++++++++++++ tests/test_export.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/decompnet/rom/export_build.py b/src/decompnet/rom/export_build.py index d3b1656e..308eaa76 100644 --- a/src/decompnet/rom/export_build.py +++ b/src/decompnet/rom/export_build.py @@ -258,6 +258,24 @@ def linker_script(provides: dict[str, str], gaps: list[tuple[int, str]]) -> str: if B.get("gp"): lines.append(f"_gp = 0x{B['gp']:08X};") lines += [f"{name} = 0x{addr:08X};" for name, addr in B["symbols"].items()] + # The splitter can leave references into raw data or across local labels in separate function objects. + # Only generated address-bearing names can supply their own absolute address. PROVIDE keeps real definitions + # authoritative; arbitrary unresolved names still fail at link time and the whole-image comparison stays fixed. + from elftools.elf.elffile import ELFFile + + auto_addresses = {} + for obj in set(provides.values()): + with open(ROOT / obj, "rb") as stream: + symtab = ELFFile(stream).get_section_by_name(".symtab") + for symbol in symtab.iter_symbols() if symtab else (): + if symbol["st_shndx"] != "SHN_UNDEF": + continue + name = symbol.name + address = AUTO_NAME.match(name) or re.fullmatch(r"\.L([0-9A-Fa-f]{8})", name) + if address: + auto_addresses[name] = int(address.group(1), 16) + lines += [f'PROVIDE("{name}" = 0x{address:08X});' for name, address in sorted(auto_addresses.items())] + # jump tables in the data point at labels inside functions (.L001006F8); with one object per function those # labels are local to another object, so give them their addresses (which their names spell out) labels = set() diff --git a/tests/test_export.py b/tests/test_export.py index 9b5032d6..513c2250 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -129,3 +129,51 @@ def test_an_agents_own_include_of_context_h_names_the_exports_header(): text = exported_source("", "common_ovl.h", '#include "context.h"\n\nint f(void) { return 1; }') assert text == '#include "common_ovl.h"\n\n#include "common_ovl.h"\n\nint f(void) { return 1; }\n' assert "context.h" not in exported_source("", "common.h", ' # include "context.h"\nint g;') + + +def test_generated_address_references_link_across_objects(tmp_path): + """Raw-data references and split local branch targets keep their encoded addresses; unknown names still fail.""" + import json + import runpy + + if toolchains.installed("binutils-mips") is None: + pytest.skip("needs pinned MIPS binutils") + # Importing export_build normally reads a build.json next to its own source, so execute a private copy instead. + from decompnet.rom import export + + shutil.copyfile(Path(export.__file__).with_name("export_build.py"), tmp_path / "build.py") + config = {"image": "baserom.z64", "symbols": {}, "functions": [ + {"name": "first", "rom": 0, "vram": 0x80000000}, + {"name": "second", "rom": 16, "vram": 0x80000010}], "segments": [ + {"name": "code", "kind": "code", "start": 0, "end": 24, "vram": 0x80000000, + "pieces": [], "bss": []}]} + (tmp_path / "build.json").write_text(json.dumps(config)) + (tmp_path / "baserom.z64").write_bytes(bytes(24)) + sources = { + "first": ".text\n.set noreorder\n.globl first\nfirst:\n" + "lui $8, %hi(D_80001000)\nlw $8, %lo(D_80001000)($8)\nb .L80000010\nnop\n", + "second": ".text\n.set noreorder\n.globl second\nsecond:\n.L80000010:\njr $31\nnop\n"} + assembler = str(toolchains.binary("binutils-mips").parent / "mips-ps2-decompals-as") + linker = str(toolchains.binary("binutils-mips").parent / "mips-ps2-decompals-ld") + for name, source in sources.items(): + (tmp_path / f"{name}.s").write_text(source) + subprocess.run([assembler, "-EB", "-mips2", "-no-pad-sections", "-o", f"{name}.o", f"{name}.s"], + cwd=tmp_path, check=True, capture_output=True) + script = runpy.run_path(str(tmp_path / "build.py")) + text = script["linker_script"]({"first": "first.o", "second": "second.o"}, []) + (tmp_path / "game.ld").write_text(text) + linked = subprocess.run([linker, "-EB", "-T", "game.ld", "-o", "game.elf", "first.o", "second.o"], + cwd=tmp_path, capture_output=True, text=True) + assert linked.returncode == 0, linked.stderr + symbols = {s.name: s["st_value"] for s in ELFFile(open(tmp_path / "game.elf", "rb")) + .get_section_by_name(".symtab").iter_symbols()} + assert symbols["D_80001000"] == 0x80001000 + assert symbols[".L80000010"] == 0x80000010 + (tmp_path / "first.s").write_text(sources["first"].replace("D_80001000", "unknown_data")) + subprocess.run([assembler, "-EB", "-mips2", "-no-pad-sections", "-o", "first.o", "first.s"], + cwd=tmp_path, check=True, capture_output=True) + text = script["linker_script"]({"first": "first.o", "second": "second.o"}, []) + (tmp_path / "game.ld").write_text(text) + linked = subprocess.run([linker, "-EB", "-T", "game.ld", "-o", "game.elf", "first.o", "second.o"], + cwd=tmp_path, capture_output=True, text=True) + assert linked.returncode != 0 and "unknown_data" in linked.stderr From af853d2f0da41ded2a9c9436ab2eb460f915b25b Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:02:51 +0100 Subject: [PATCH 3/3] Handle RNC values spanning multiple control words --- src/decompnet/consoles/n64/rnc.py | 4 +++- tests/test_rnc.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/decompnet/consoles/n64/rnc.py b/src/decompnet/consoles/n64/rnc.py index 0bbc45c0..2abb13f7 100644 --- a/src/decompnet/consoles/n64/rnc.py +++ b/src/decompnet/consoles/n64/rnc.py @@ -58,8 +58,10 @@ def word(self, pos: int) -> int: return self.data[pos] | self.data[pos + 1] << 8 def take(self, count: int) -> int: - if not 0 <= count <= 16: + if not 0 <= count <= 30: raise RncError("invalid RNC bit count") + if count > 16: + return self.take(16) | self.take(count - 16) << 16 if not count: return 0 value = self.buffer & ((1 << count) - 1) diff --git a/tests/test_rnc.py b/tests/test_rnc.py index 91839a64..fe4ea8f5 100644 --- a/tests/test_rnc.py +++ b/tests/test_rnc.py @@ -48,7 +48,7 @@ def literal_block(output): return w.container(output) -@pytest.mark.parametrize('size', [1, 2, 3, 17, 256, 4096]) +@pytest.mark.parametrize('size', [1, 2, 3, 17, 256, 4096, 131072]) def test_literals(size): output = bytes(i % 251 for i in range(size)) locations = []