From 470ccb37038ad2bb4c0add6501a320a31b8795ef Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:34:12 +0000 Subject: [PATCH 1/2] Add targeted address, padding and local-width repairs --- ROADMAP.md | 6 ++ src/decompnet/app/draft_repairs.py | 16 +++++ src/decompnet/app/local_experiments.py | 14 +++- src/decompnet/app/local_width_repairs.py | 40 +++++++++++ src/decompnet/app/pointer_cast_repairs.py | 54 +++++++++++++++ src/decompnet/app/repair_ranking.py | 4 +- src/decompnet/app/stack_padding_repairs.py | 40 +++++++++++ src/decompnet/app/store_address_repairs.py | 79 ++++++++++++++++++++++ tests/test_local_width_repairs.py | 35 ++++++++++ tests/test_pointer_cast_repairs.py | 40 +++++++++++ tests/test_stack_padding_repairs.py | 38 +++++++++++ tests/test_store_address_repairs.py | 41 +++++++++++ tests/test_targeted_repair_families.py | 33 +++++++++ 13 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 src/decompnet/app/local_width_repairs.py create mode 100644 src/decompnet/app/pointer_cast_repairs.py create mode 100644 src/decompnet/app/stack_padding_repairs.py create mode 100644 src/decompnet/app/store_address_repairs.py create mode 100644 tests/test_local_width_repairs.py create mode 100644 tests/test_pointer_cast_repairs.py create mode 100644 tests/test_stack_padding_repairs.py create mode 100644 tests/test_store_address_repairs.py create mode 100644 tests/test_targeted_repair_families.py diff --git a/ROADMAP.md b/ROADMAP.md index 82acae80..3d11ab20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -848,3 +848,9 @@ ROM and imported briefs via get_function(section="quality"). Existing notes are These are not semantic proofs: best scores, saved best C and the exact judge remain unchanged. Alternatives must be rejudged in their current context. Custom generated dependencies outside the observed build/include files still need richer build-system dependency records. + +Shared deterministic repairs can stage a scalar store's byte address, route repeated pointer-to-integer casts +through an inline helper on supported 32-bit processors, remove/shrink unreferenced local padding, and narrow +an ordinary local when its existing uses already contain narrow casts. ROM and imported projects use the same +bounded draft/local-family pipelines and ordinary exact judges. These remain source hypotheses, not semantic +proofs or automatic compiler-setting changes; volatile, escaping and ambiguous cases are left alone. diff --git a/src/decompnet/app/draft_repairs.py b/src/decompnet/app/draft_repairs.py index 020ec638..2d5429fb 100644 --- a/src/decompnet/app/draft_repairs.py +++ b/src/decompnet/app/draft_repairs.py @@ -14,11 +14,15 @@ expression_repairs, lifetime_repairs, load_pair_repairs, + local_width_repairs, mmio_repairs, near_repairs, pattern_repairs, + pointer_cast_repairs, register_repairs, + stack_padding_repairs, storage_scope_repairs, + store_address_repairs, ) LIMIT = 20 @@ -57,6 +61,18 @@ def variants(source: str, name: str, arch: str, asm: str = '', flags=(), labels= for family,text in lifetime_repairs.variants(source,name): proposals.append(text) labels[text]=family + for text in store_address_repairs.variants(source, name, arch): + proposals.append(text) + labels[text] = 'store_address_lifetime' + for text in stack_padding_repairs.variants(source, name): + proposals.append(text) + labels[text] = 'stack_padding' + for text in local_width_repairs.variants(source, name): + proposals.append(text) + labels[text] = 'local_width' + for text in pointer_cast_repairs.variants(source, name, arch): + proposals.append(text) + labels[text] = 'pointer_cast_inline' # Fresh m2c drafts already get this alternative in autopilot. Saved/transferred drafts need the # same chance, with their macro typedef block kept out of the refinement's type substitutions. if arch == 'mips' and ('M2C_FIELD(' in source or 'M2C_UNK' in source or re.search(r'\barg[0-3]\b',source)): diff --git a/src/decompnet/app/local_experiments.py b/src/decompnet/app/local_experiments.py index 2eadb142..eaee9afe 100644 --- a/src/decompnet/app/local_experiments.py +++ b/src/decompnet/app/local_experiments.py @@ -12,15 +12,19 @@ expression_repairs, lifetime_repairs, load_pair_repairs, + local_width_repairs, mmio_repairs, pattern_repairs, + pointer_cast_repairs, register_repairs, repair_ranking, + stack_padding_repairs, + store_address_repairs, ) FAMILIES = ('signedness','declaration_order','typed_temporary','branch_orientation','narrowing','source_lines', 'address_temporary','constant_local','comparison_load','stack_array','loop_induction', - 'parameter_width','value_lifetime','sum_shape','call_result_inline','comparison_reuse','integer_address_order','mmio_address','call_arity','struct_byte_offset','switch_call','paired_load_order','array_index_width','split_scalar_lifetime','pointer_before_store') + 'parameter_width','value_lifetime','sum_shape','call_result_inline','comparison_reuse','integer_address_order','mmio_address','call_arity','struct_byte_offset','switch_call','paired_load_order','array_index_width','split_scalar_lifetime','pointer_before_store','store_address_lifetime','stack_padding','local_width','pointer_cast_inline') def run(net, agent, slug, function, revision, families=None, max_trials=6): @@ -60,6 +64,14 @@ def run(net, agent, slug, function, revision, families=None, max_trials=6): for family,candidate in lifetime_repairs.variants(source,fn['name']): if family in pools: pools[family].append(candidate) + if 'pointer_cast_inline' in pools: + pools['pointer_cast_inline'] = pointer_cast_repairs.variants(source,fn['name'],proj['arch']) + if 'local_width' in pools: + pools['local_width'] = local_width_repairs.variants(source,fn['name']) + if 'stack_padding' in pools: + pools['stack_padding'] = stack_padding_repairs.variants(source,fn['name']) + if 'store_address_lifetime' in pools: + pools['store_address_lifetime'] = store_address_repairs.variants(source,fn['name'],proj['arch']) if 'paired_load_order' in pools: pools['paired_load_order']=load_pair_repairs.variants(source,fn['name']) if 'mmio_address' in pools: diff --git a/src/decompnet/app/local_width_repairs.py b/src/decompnet/app/local_width_repairs.py new file mode 100644 index 00000000..3a487ac0 --- /dev/null +++ b/src/decompnet/app/local_width_repairs.py @@ -0,0 +1,40 @@ +"""Try a narrower local when the candidate already casts that value to a narrow type. + +A byte store's cast can be too late to reproduce the original allocator. These hypotheses change only +an ordinary scalar declaration; the exact judge, not the cast alone, decides whether to keep them. +""" +import re + +from ..forks.csource import _blank, find + +IDENT = r'[A-Za-z_]\w*' + + +def variants(source, name): + span = find(source, name) + if not span: + return [] + clean = _blank(source) + opening = clean.find('{', *span) + if opening < 0 or '#' in clean[opening:span[1]] or re.search(r'\bvolatile\b', clean): + return [] + body = clean[opening:span[1]] + result = [] + declaration = re.compile(r'(?:^|(?<=[;{}]))\s*(?Ps32|u32|int|unsigned\s+int)\s+(?P'+IDENT+r')\s*;') + for hit in declaration.finditer(body): + var = hit['name'] + if re.search(r'&\s*'+re.escape(var)+r'\b', body): + continue + cast = re.search(r'\(\s*(s8|u8|char|signed\s+char|unsigned\s+char|s16|u16|short|unsigned\s+short)\s*\)\s*'+re.escape(var)+r'\b', body[hit.end():]) + if not cast: + continue + # A name reused in an inner scope is ambiguous; don't retype a different declaration. + if len(re.findall(r'\b(?:s32|u32|int|unsigned\s+int|char|short|[su](?:8|16))\s+'+re.escape(var)+r'\s*[;=,]', body)) != 1: + continue + choices = ('char', 'signed char', 'unsigned char') if cast[1] in {'s8','u8','char','signed char','unsigned char'} else ('short','unsigned short') + for typ in choices: + a, b = opening+hit.start('type'), opening+hit.end('type') + result.append(source[:a]+typ+source[b:]) + if len(result) >= 6: + break + return result[:6] diff --git a/src/decompnet/app/pointer_cast_repairs.py b/src/decompnet/app/pointer_cast_repairs.py new file mode 100644 index 00000000..7eece114 --- /dev/null +++ b/src/decompnet/app/pointer_cast_repairs.py @@ -0,0 +1,54 @@ +"""Inline a repeated pointer-to-integer cast through a small helper for old register allocators. + +The source must declare the operand as a pointer. This preserves evaluations and keeps the compiler's +inlining choice provisional: only the ordinary exact judge can accept the resulting object. +""" +import re + +from ..forks.csource import _blank, find + +IDENT = r'[A-Za-z_]\w*' + + +def variants(source, name, arch): + if arch not in {'mips', 'ppc', 'x86', 'arm', 'thumb'}: + return [] + span = find(source, name) + if not span: + return [] + clean = _blank(source) + opening = clean.find('{', *span) + if opening < 0 or '#' in clean[opening:span[1]] or re.search(r'\bvolatile\b', clean): + return [] + helper = '__repair_pointer_bits' + if re.search(r'\b'+helper+r'\b', clean): + return [] + pointers = re.findall(r'\b(?:void|char|[su](?:8|16|32)|unsigned\s+(?:char|short|int))\s*\*\s*('+IDENT+r')\s*[;,)]', clean) + body = clean[opening:span[1]] + cast = re.compile(r'\(\s*(?Ps32|u32|int|unsigned\s+int)\s*\)\s*(?P'+IDENT+r'(?:\s*->\s*'+IDENT+r')*)(?!\w|\s*\()') + groups = {} + for hit in cast.finditer(body): + expr = re.sub(r'\s+', '', hit['expr']) + pointer = expr.split('->')[-1] + if pointers.count(pointer) != 1: + continue + # Don't take a prefix out of indexed/longer expressions or post-increment a pointer. + if re.match(r'\s*(?:\[|\.|->|\+\+|--)', body[hit.end():]): + continue + groups.setdefault((hit['type'], expr), []).append(hit) + result = [] + for (typ, expr), hits in groups.items(): + if len(hits) < 2: + continue + text = source + for hit in reversed(hits): + a, b = opening+hit.start(), opening+hit.end() + operand = source[opening+hit.start('expr'):opening+hit.end('expr')] + text = text[:a]+helper+'('+operand+')'+text[b:] + # Built-in return types make the helper independent of typedef placement in the candidate. + returned = 'unsigned int' if typ in {'u32', 'unsigned int'} else 'int' + prefix = 'static inline '+returned+' '+helper+'(void *value)\n{\n return ('+returned+')value;\n}\n\n' + result.append(prefix+text) + if len(result) >= 2: + break + return result diff --git a/src/decompnet/app/repair_ranking.py b/src/decompnet/app/repair_ranking.py index bb6cac13..578420d1 100644 --- a/src/decompnet/app/repair_ranking.py +++ b/src/decompnet/app/repair_ranking.py @@ -3,8 +3,8 @@ def order(families, rows): markers = {row[0] for row in rows if row[0] != ' '} if markers and markers <= {'r', 's'}: - preferred = ('stack_array', 'parameter_width', 'declaration_order') if 's' in markers else ( - 'comparison_reuse', 'integer_address_order', 'typed_temporary', 'value_lifetime', 'split_scalar_lifetime', 'array_index_width', 'declaration_order') + preferred = ('stack_padding', 'stack_array', 'parameter_width', 'declaration_order') if 's' in markers else ( + 'store_address_lifetime', 'pointer_cast_inline', 'comparison_reuse', 'integer_address_order', 'typed_temporary', 'value_lifetime', 'split_scalar_lifetime', 'array_index_width', 'local_width', 'declaration_order') elif markers & {'<', '>', '|', 'o'}: preferred = ('paired_load_order', 'pointer_before_store', 'branch_orientation', 'loop_induction', 'call_result_inline', 'parameter_width') elif 'i' in markers: diff --git a/src/decompnet/app/stack_padding_repairs.py b/src/decompnet/app/stack_padding_repairs.py new file mode 100644 index 00000000..8eca3274 --- /dev/null +++ b/src/decompnet/app/stack_padding_repairs.py @@ -0,0 +1,40 @@ +"""Remove or shrink unused local padding; exact comparison decides the original frame size.""" +import re + +from ..forks.csource import _blank, find + +IDENT = r'[A-Za-z_]\w*' +TYPES = r'(?:[su](?:8|16|32)|char|short|int|unsigned\s+(?:char|short|int))' + + +def variants(source, name): + span = find(source, name) + if not span: + return [] + clean = _blank(source) + opening = clean.find('{', *span) + if opening < 0 or '#' in clean[opening:span[1]]: + return [] + body = clean[opening:span[1]] + declaration = re.compile(r'(?:^|(?<=[;{}]))\s*(?P'+TYPES+r'\s+(?P(?:pad|padding)\w*)\s*' + r'\[\s*(?P0x[\da-fA-F]+|\d+)\s*\]\s*;)') + result = [] + for hit in declaration.finditer(body): + # Only ordinary declarations in the function's outer scope, never aggregate fields, + # initialized arrays, qualifiers or storage whose address/sizeof is referenced. + prefix = body[:hit.start('decl')] + if prefix.count('{')-prefix.count('}') != 1: + continue + if len(re.findall(r'\b'+re.escape(hit['name'])+r'\b', body)) != 1: + continue + count = int(hit['count'], 16 if hit['count'].startswith('0x') else 10) + if not 1 <= count <= 64: + continue + a, b = opening+hit.start('decl'), opening+hit.end('decl') + result.append(source[:a]+source[b:]) + if count > 1: + a, b = opening+hit.start('count'), opening+hit.end('count') + result.append(source[:a]+str(count//2)+source[b:]) + if len(result) >= 4: + break + return result diff --git a/src/decompnet/app/store_address_repairs.py b/src/decompnet/app/store_address_repairs.py new file mode 100644 index 00000000..d288810b --- /dev/null +++ b/src/decompnet/app/store_address_repairs.py @@ -0,0 +1,79 @@ +"""Stage a scalar store's byte address without moving its loads across other statements. + +Older compilers can choose a different operand order for an address kept in a pointer temporary. +These bounded source hypotheses still need the ordinary linked/object judge to accept them. +""" +import re + +from ..forks.csource import _blank, find +from .register_repairs import unwrap + +IDENT = r'[A-Za-z_]\w*' +SCALAR = r'(?:[su](?:8|16|32)|char|short|int|unsigned\s+(?:char|short|int))' +NUMBER = r'(?:0x[0-9a-fA-F]+|[0-9]+)' + + +def split_sum(text): + """Find a binary addition outside casts and nested address expressions.""" + depth = 0 + for pos, token in enumerate(text): + depth += (token in '([') - (token in ')]') + if token == '+' and depth == 0: + return text[:pos].strip(), text[pos+1:].strip() + return None + + +def variants(source, name, arch): + if arch not in {'mips', 'ppc', 'x86', 'arm', 'thumb'}: + return [] + span = find(source, name) + if not span: + return [] + clean = _blank(source) + opening = clean.find('{', *span) + if opening < 0 or re.search(r'\bvolatile\b', clean) or '#' in clean[opening:span[1]]: + return [] + body = clean[opening+1:span[1]-1] + result = [] + fresh = '__repair_store_address' + if re.search(r'\b'+fresh+r'\b', clean): + return [] + # The RHS is a simple value: do not reorder an address load around a call or another store. + store = re.compile(r'(?:^|(?<=[;{}]))\s*(?P\*[^;{}]+?)\s*=(?!=)\s*(?P'+IDENT+'|'+NUMBER+r')\s*;') + for hit in store.finditer(body): + lhs = unwrap(hit['lhs'][1:]) + cast = re.match(r'\(\s*('+SCALAR+r')\s*\*\s*\)', lhs) + if not cast: + continue + address = unwrap(lhs[cast.end():]) + outer = split_sum(address) + if not outer or not re.fullmatch(NUMBER, outer[1]): + continue + bytecast = re.match(r'\(\s*(?:char|u8|s8|unsigned\s+char)\s*\*\s*\)', unwrap(outer[0])) + if not bytecast: + continue + term = unwrap(outer[0]) + summed = split_sum(unwrap(term[bytecast.end():])) + if not summed: + continue + left, right = summed + # One base identifier and one nontrivial offset. No calls, increments, assignments or comma + # operators, and no unknown character tokens. Every original load is evaluated exactly once. + if re.fullmatch(IDENT, right): + base, index = right, left + elif re.fullmatch(IDENT, left): + base, index = left, right + else: + continue + if not re.search(r'[\[\]*]', index) or re.search(r'\+\+|--|[=,?:;{}]|\b'+IDENT+r'\s*\(', index): + continue + replacement = ('{ char *'+fresh+' = (char *)'+base+'; '+fresh+' += '+index+'; ' + '*('+cast[1]+' *)('+fresh+' + '+outer[1]+') = '+hit['rhs']+'; }') + start = opening+1+hit.start('lhs') + end = opening+1+hit.end() + candidate = source[:start]+replacement+source[end:] + if candidate != source and candidate not in result: + result.append(candidate) + if len(result) >= 4: + break + return result diff --git a/tests/test_local_width_repairs.py b/tests/test_local_width_repairs.py new file mode 100644 index 00000000..2a3608c0 --- /dev/null +++ b/tests/test_local_width_repairs.py @@ -0,0 +1,35 @@ +import pytest + +from decompnet.app import draft_repairs, local_experiments, local_width_repairs + +SOURCE = 'void f(unsigned int bits) { do { unsigned int value; value = bits & 15; store((signed char)value); } while (0); }' + + +def test_local_width_uses_an_existing_narrow_cast_as_its_hint(): + choices = local_width_repairs.variants(SOURCE, 'f') + assert len(choices) == 3 + assert any('char value;' in s for s in choices) + assert all('value = bits & 15;' in s and 'store((signed char)value);' in s for s in choices) + short = local_width_repairs.variants(SOURCE.replace('signed char', 'short'), 'f') + assert len(short) == 2 and any('short value;' in s for s in short) + + +@pytest.mark.parametrize('source', [ + SOURCE.replace('store((signed char)value)', 'store(value)'), + SOURCE.replace('unsigned int value;', 'volatile unsigned int value;'), + SOURCE.replace('value = bits', 'use(&value); value = bits'), + SOURCE.replace('value = bits', '{int value;store((signed char)value);} value = bits'), + SOURCE.replace('value = bits', '#define X 1\nvalue = bits'), +]) +def test_local_width_skips_unhinted_volatile_escaped_shadowed_or_macro_candidates(source): + assert not local_width_repairs.variants(source, 'f') + + +def test_local_width_preserves_other_definitions_and_reaches_both_searches(): + prefix = 'void other(void) { unsigned int value; }\n' + choices = local_width_repairs.variants(prefix+SOURCE, 'f') + assert choices and all(s.startswith(prefix) for s in choices) + labels = {} + choices = draft_repairs.variants(SOURCE, 'f', 'mips', labels=labels) + assert any(labels[s] == 'local_width' for s in choices) + assert 'local_width' in local_experiments.FAMILIES diff --git a/tests/test_pointer_cast_repairs.py b/tests/test_pointer_cast_repairs.py new file mode 100644 index 00000000..82fde63e --- /dev/null +++ b/tests/test_pointer_cast_repairs.py @@ -0,0 +1,40 @@ +import pytest + +from decompnet.app import draft_repairs, local_experiments, pointer_cast_repairs + +SOURCE = 'typedef struct { unsigned char *data; } Node; void f(Node *n) { int x; x=(int)n->data; use(x); x=(int)n->data; use(x); }' + + +def test_repeated_pointer_casts_can_share_an_inline_conversion(): + choices = pointer_cast_repairs.variants(SOURCE, 'f', 'mips') + assert len(choices) == 1 + candidate = choices[0] + assert candidate.startswith('static inline int __repair_pointer_bits(void *value)') + assert candidate.count('__repair_pointer_bits(n->data)') == 2 + assert candidate.count('use(x);') == 2 + assert 'typedef struct { unsigned char *data; } Node;' in candidate + + +@pytest.mark.parametrize('source', [ + SOURCE.replace('unsigned char *data;', 'int data;'), + SOURCE.replace('(int)n->data', '(int)next()'), + SOURCE.replace('(int)n->data', '(int)n->data++'), + SOURCE.replace('(int)n->data', '(int)n->data[0]'), + SOURCE.replace('int x;', 'volatile int x;'), + SOURCE.replace('int x;', 'int x;\n#define VALUE 1\n'), + SOURCE.replace('int x;', 'int x; int __repair_pointer_bits;'), +]) +def test_pointer_cast_helpers_require_unambiguous_pointer_operands(source): + assert not pointer_cast_repairs.variants(source, 'f', 'mips') + + +def test_pointer_cast_helpers_preserve_neighbours_and_reach_both_searches(): + prefix = 'void other(void) { use(3); }\n' + choices = pointer_cast_repairs.variants(prefix+SOURCE, 'f', 'mips') + assert choices and prefix in choices[0] + assert not pointer_cast_repairs.variants(SOURCE, 'f', 'aarch64') + assert not pointer_cast_repairs.variants(SOURCE.replace('x=(int)n->data; use(x);', '', 1), 'f', 'mips') + labels = {} + choices = draft_repairs.variants(SOURCE, 'f', 'mips', labels=labels) + assert any(labels[s] == 'pointer_cast_inline' for s in choices) + assert 'pointer_cast_inline' in local_experiments.FAMILIES diff --git a/tests/test_stack_padding_repairs.py b/tests/test_stack_padding_repairs.py new file mode 100644 index 00000000..09b2e5ee --- /dev/null +++ b/tests/test_stack_padding_repairs.py @@ -0,0 +1,38 @@ +import pytest + +from decompnet.app import draft_repairs, local_experiments, stack_padding_repairs + + +def test_unreferenced_integer_padding_can_be_removed_or_halved(): + source = 'int f(void) { int pad[4]; return work(); }' + choices = stack_padding_repairs.variants(source, 'f') + assert len(choices) == 2 + assert any('pad[' not in s for s in choices) + assert any('int pad[2];' in s for s in choices) + assert all('return work();' in s for s in choices) + + +@pytest.mark.parametrize('declaration,tail', [ + ('volatile int pad[4];', ''), ('const int pad[4];', ''), + ('int pad[4]={0};', ''), ('int pad[4];', 'use(pad);'), + ('int pad[4];', 'use(sizeof(pad));'), ('int pad[4];', 'use(&pad);'), + ('struct { int pad[4]; } entry;', ''), ('int padding[65];', ''), +]) +def test_padding_repairs_preserve_observable_storage_and_aggregate_fields(declaration, tail): + assert not stack_padding_repairs.variants('void f(void) {'+declaration+tail+'}', 'f') + + +def test_padding_repairs_keep_other_definitions_and_ignore_comments(): + prefix = 'void other(void) { int pad[4]; }\n' + source = prefix+'void f(void) { int pad[2]; /* pad is unused */ work(); }' + choices = stack_padding_repairs.variants(source, 'f') + assert choices and all(s.startswith(prefix) for s in choices) + assert all('/* pad is unused */' in s for s in choices) + assert not stack_padding_repairs.variants('void f(void) {\n#define N 4\nint pad[N];}', 'f') + + +def test_padding_repairs_reach_both_local_search_entry_points(): + labels = {} + choices = draft_repairs.variants('void f(void) { int pad[4]; work(); }', 'f', 'mips', labels=labels) + assert any(labels[s] == 'stack_padding' for s in choices) + assert 'stack_padding' in local_experiments.FAMILIES diff --git a/tests/test_store_address_repairs.py b/tests/test_store_address_repairs.py new file mode 100644 index 00000000..27719b8a --- /dev/null +++ b/tests/test_store_address_repairs.py @@ -0,0 +1,41 @@ +import pytest + +from decompnet.app import draft_repairs, local_experiments, store_address_repairs + +SOURCE = '''void f(void *node, int base) { + *((short *)((char *)((*(int *)((char *)node + 8) * 20) + base) + 12)) = 0; +}''' + + +def test_store_address_lifetime_keeps_each_load_in_its_statement(): + choices = store_address_repairs.variants(SOURCE, 'f', 'mips') + assert len(choices) == 1 + candidate = choices[0] + assert 'char *__repair_store_address = (char *)base;' in candidate + assert '__repair_store_address += (*(int *)((char *)node + 8) * 20);' in candidate + assert '*(short *)(__repair_store_address + 12) = 0;' in candidate + assert candidate.count('*(int *)') == 1 + assert candidate.startswith('void f(void *node, int base) {') + + +@pytest.mark.parametrize('replacement', ['next()', 'index++', 'index = 3', '(index, 3)']) +def test_store_address_lifetime_does_not_split_side_effecting_offsets(replacement): + source = SOURCE.replace('*(int *)((char *)node + 8) * 20', replacement) + assert not store_address_repairs.variants(source, 'f', 'mips') + + +def test_store_address_lifetime_leaves_calls_volatile_and_other_functions_alone(): + assert not store_address_repairs.variants(SOURCE.replace('= 0;', '= call();'), 'f', 'mips') + assert not store_address_repairs.variants(SOURCE.replace('int base', 'volatile int base'), 'f', 'mips') + assert not store_address_repairs.variants(SOURCE, 'f', 'aarch64') + assert not store_address_repairs.variants(SOURCE.replace('base)', 'base); #define X 1\n'), 'f', 'mips') + prefix = 'void other(void) { /* preserve this body */ }\n' + assert store_address_repairs.variants(prefix + SOURCE, 'f', 'mips')[0].startswith(prefix) + assert not store_address_repairs.variants(SOURCE.replace('base)', '__repair_store_address)'), 'f', 'mips') + + +def test_store_address_lifetime_reaches_autopilot_and_agent_local_trials(): + labels = {} + choices = draft_repairs.variants(SOURCE, 'f', 'mips', labels=labels) + assert any(labels[s] == 'store_address_lifetime' for s in choices) + assert 'store_address_lifetime' in local_experiments.FAMILIES diff --git a/tests/test_targeted_repair_families.py b/tests/test_targeted_repair_families.py new file mode 100644 index 00000000..2f51c953 --- /dev/null +++ b/tests/test_targeted_repair_families.py @@ -0,0 +1,33 @@ +"""New source hypotheses still go through the ordinary mode-specific judge.""" +import pytest + +from decompnet.app import draft_repairs +from decompnet.app.permute import State +from tests.test_draft_repairs import setup_repair_net +from tests.test_permute import AGENT, ProjectStub, ScriptedPermuter + + +@pytest.mark.parametrize('imported', [False, True]) +@pytest.mark.parametrize('family,source', [ + ('store_address_lifetime', 'void f1(void*p,int base){*((short *)((char *)((*(int *)p*20)+base)+12))=0;}'), + ('stack_padding', 'void f1(void){int pad[4];work();}'), + ('local_width', 'void f1(int bits){unsigned int value;value=bits&15;store((char)value);}'), + ('pointer_cast_inline', 'void f1(unsigned char *ptr){int x;x=(int)ptr;use(x);x=(int)ptr;use(x);}'), +]) +def test_targeted_families_use_the_normal_mode_judge(imported, family, source): + net = setup_repair_net() + with net.store.tx() as db: + db.execute('UPDATE functions SET best_source=?', (source,)) + if imported: + net.fork_slug = lambda slug: 'fork' + class Fork(ProjectStub): + def permuter_header(self, slug, function): + return '' + net.forks = Fork() + labels = {} + choices = draft_repairs.variants(source, 'f1', 'mips', labels=labels) + expected = next(s for s in choices if labels[s] == family) + permuter = ScriptedPermuter(net, {expected: 0}) + state = State() + permuter._repair_round(AGENT, net.project_row('game'), state) + assert state.matched == 1 From bb6be58d74d42015f5af6866f0107b15ba68157e Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:41:48 +0000 Subject: [PATCH 2/2] Use measured frame differences for bounded padding repairs --- ROADMAP.md | 4 +++ src/decompnet/app/iterative_repairs.py | 5 +++- src/decompnet/app/local_experiments.py | 3 +++ src/decompnet/app/stack_padding_repairs.py | 19 +++++++++++++ src/decompnet/arch/__init__.py | 6 +++-- src/decompnet/arch/mips_stack.py | 31 ++++++++++++++++++++++ tests/test_stack_padding_repairs.py | 26 ++++++++++++++++++ 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/decompnet/arch/mips_stack.py diff --git a/ROADMAP.md b/ROADMAP.md index 3d11ab20..80e6f885 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -854,3 +854,7 @@ through an inline helper on supported 32-bit processors, remove/shrink unreferen an ordinary local when its existing uses already contain narrow casts. ROM and imported projects use the same bounded draft/local-family pipelines and ordinary exact judges. These remain source hypotheses, not semantic proofs or automatic compiler-setting changes; volatile, escaping and ambiguous cases are left alone. +Measured MIPS stack-only diffs can also propose two small missing-padding hypotheses when the prologue, +epilogue and every changed saved-register slot agree on the same positive frame delta. The optional iterative +phase uses its existing budget; agent local trials require measured evidence for their current source. Other +processors still use padding removal/shrinking until they have a corresponding frame reader. diff --git a/src/decompnet/app/iterative_repairs.py b/src/decompnet/app/iterative_repairs.py index 1695985d..705ebbcf 100644 --- a/src/decompnet/app/iterative_repairs.py +++ b/src/decompnet/app/iterative_repairs.py @@ -61,9 +61,12 @@ def output_key(report): def targeted_variants(source, report, fn, arch, flags): - from . import draft_repairs, repair_ranking + from . import draft_repairs, repair_ranking, stack_padding_repairs labels = {} proposals = draft_repairs.variants(source, fn['name'], arch, fn['asm'] or '', flags, labels) + for text in stack_padding_repairs.grow(source, fn['name'], arch, report.get('rows') or []): + proposals.append(text) + labels[text] = 'stack_padding' families = repair_ranking.order(list(dict.fromkeys(labels[p] for p in proposals)), report.get('rows') or []) return sorted(proposals, key=lambda p: families.index(labels[p])) diff --git a/src/decompnet/app/local_experiments.py b/src/decompnet/app/local_experiments.py index eaee9afe..487530c2 100644 --- a/src/decompnet/app/local_experiments.py +++ b/src/decompnet/app/local_experiments.py @@ -70,6 +70,9 @@ def run(net, agent, slug, function, revision, families=None, max_trials=6): pools['local_width'] = local_width_repairs.variants(source,fn['name']) if 'stack_padding' in pools: pools['stack_padding'] = stack_padding_repairs.variants(source,fn['name']) + measured = getattr(net,'_agent_outcomes',{}).get((agent.id,fn['id'])) + if measured and measured.get('source') == source: + pools['stack_padding'] += stack_padding_repairs.grow(source,fn['name'],proj['arch'],measured.get('rows') or []) if 'store_address_lifetime' in pools: pools['store_address_lifetime'] = store_address_repairs.variants(source,fn['name'],proj['arch']) if 'paired_load_order' in pools: diff --git a/src/decompnet/app/stack_padding_repairs.py b/src/decompnet/app/stack_padding_repairs.py index 8eca3274..ff34d281 100644 --- a/src/decompnet/app/stack_padding_repairs.py +++ b/src/decompnet/app/stack_padding_repairs.py @@ -1,6 +1,7 @@ """Remove or shrink unused local padding; exact comparison decides the original frame size.""" import re +from .. import arch as arches from ..forks.csource import _blank, find IDENT = r'[A-Za-z_]\w*' @@ -38,3 +39,21 @@ def variants(source, name): if len(result) >= 4: break return result + + +def grow(source, name, arch, rows): + """Try missing local storage only when every differing instruction agrees on one frame delta.""" + reader = arches.get(arch).stack_frame_growth + delta = reader(rows) if reader else None + if delta is None: + return [] + span = find(source, name) + if not span: + return [] + clean = _blank(source) + opening = clean.find('{', *span) + fresh = '__repair_stack_padding' + if opening < 0 or '#' in clean[opening:span[1]] or re.search(r'\b(?:volatile|'+fresh+r')\b', clean): + return [] + return [source[:opening+1]+'\nchar '+fresh+'['+str(count)+'];'+source[opening+1:] + for count in (delta, delta//2)] diff --git a/src/decompnet/arch/__init__.py b/src/decompnet/arch/__init__.py index d89266fb..2a54c6b4 100644 --- a/src/decompnet/arch/__init__.py +++ b/src/decompnet/arch/__init__.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from typing import Any -from . import arm, mips, mips_fields, mips_tools, ppc, x86, x86_fields +from . import arm, mips, mips_fields, mips_stack, mips_tools, ppc, x86, x86_fields # What decompdecomp's readers can do with a processor's code (each is a module of its own that reads MIPS, and some # x86): @@ -46,13 +46,15 @@ class Arch: method_draft: Callable[..., str | None] | None = None # variants of it capstone can't decode (Console.cpu_variant) -> their own reader: (code, big_endian) -> # [(offset, mnemonic, operands)], for scoring's diffs (MIPS: the PSP's Allegrex, read by rabbitizer) + # Uniform saved-register/frame shifts in scored instruction pairs, for bounded padding hypotheses. + stack_frame_growth: Callable[..., int | None] | None = None variants: dict[str, Callable[[bytes, bool], list[tuple[int, str, str]]]] = field(default_factory=dict) ARCHES = [ Arch("mips", "MIPS", mips.CAPSTONE, mips.RELOC_MASKS, mips.asm_reason, True, frozenset({COMPILER_MAP, CLASSES, BOUNDARIES, STRUCTS}), mips_fields.read_facts, - build_tool_paths=mips_tools.build_paths, variants={mips.ALLEGREX: mips.allegrex_lines}), + build_tool_paths=mips_tools.build_paths, stack_frame_growth=mips_stack.frame_growth, variants={mips.ALLEGREX: mips.allegrex_lines}), Arch("ppc", "PowerPC", ppc.CAPSTONE, ppc.RELOC_MASKS, ppc.asm_reason), Arch("x86", "x86", x86.CAPSTONE, x86.RELOC_MASKS, features=frozenset({CLASSES, STRUCTS}), field_accesses=x86_fields.read_facts, float_operands=x86.float_operands, method_draft=x86.method_draft), diff --git a/src/decompnet/arch/mips_stack.py b/src/decompnet/arch/mips_stack.py new file mode 100644 index 00000000..56f8e858 --- /dev/null +++ b/src/decompnet/arch/mips_stack.py @@ -0,0 +1,31 @@ +"""Read a uniform MIPS frame-size difference from scored instruction pairs; no match verdicts.""" +import re + + +def frame_growth(rows): + """A small positive frame delta with unchanged saved-register instructions, or no evidence.""" + if not rows: + return None + changed = [row for row in rows if row[0] != ' '] + if len(changed) < 4 or any(row[0] != 's' for row in changed): + return None + frame = re.compile(r'addiu\s+\$sp,\s*\$sp,\s*(-?0x[0-9a-fA-F]+|-?\d+)$') + ends = [] + for _, target, candidate in changed: + a, b = frame.fullmatch(target), frame.fullmatch(candidate) + if a and b: + ends.append((int(a[1], 0), int(b[1], 0))) + if len(ends) != 2 or ends[0][0] >= 0 or ends[0][1] >= 0 or ends[1] != (-ends[0][0], -ends[0][1]): + return None + delta = abs(ends[0][0])-abs(ends[0][1]) + if delta not in {8, 16, 24, 32}: + return None + # Saved-register loads/stores must all show the same shift, without changing registers/opcodes. + slot = re.compile(r'((?:sw|lw|sdc1|ldc1)\s+\$\w+,\s*)(0x[0-9a-fA-F]+|\d+)\(\$sp\)$') + for _, target, candidate in changed: + if frame.fullmatch(target) and frame.fullmatch(candidate): + continue + a, b = slot.fullmatch(target), slot.fullmatch(candidate) + if not a or not b or a[1] != b[1] or int(a[2], 0)-int(b[2], 0) != delta: + return None + return delta diff --git a/tests/test_stack_padding_repairs.py b/tests/test_stack_padding_repairs.py index 09b2e5ee..9cf34e37 100644 --- a/tests/test_stack_padding_repairs.py +++ b/tests/test_stack_padding_repairs.py @@ -36,3 +36,29 @@ def test_padding_repairs_reach_both_local_search_entry_points(): choices = draft_repairs.variants('void f(void) { int pad[4]; work(); }', 'f', 'mips', labels=labels) assert any(labels[s] == 'stack_padding' for s in choices) assert 'stack_padding' in local_experiments.FAMILIES + + +ROWS = [('s', 'addiu $sp, $sp, -0x28', 'addiu $sp, $sp, -0x20'), + ('s', 'sw $ra, 0x20($sp)', 'sw $ra, 0x18($sp)'), + ('s', 'lw $ra, 0x20($sp)', 'lw $ra, 0x18($sp)'), + ('s', 'addiu $sp, $sp, 0x28', 'addiu $sp, $sp, 0x20')] + + +def test_frame_growth_requires_a_consistent_stack_only_report(): + source = 'void f(void) { work(); }' + choices = stack_padding_repairs.grow(source, 'f', 'mips', ROWS) + assert len(choices) == 2 + assert 'char __repair_stack_padding[8];' in choices[0] + assert not stack_padding_repairs.grow(source, 'f', 'ppc', ROWS) + assert not stack_padding_repairs.grow(source, 'f', 'mips', ROWS[:-1]) + assert not stack_padding_repairs.grow(source, 'f', 'mips', [('r', *ROWS[0][1:])]+ROWS[1:]) + assert not stack_padding_repairs.grow(source, 'f', 'mips', [ROWS[0], ('s','sw $s0, 0x20($sp)','sw $ra, 0x18($sp)'), *ROWS[2:]]) + assert not stack_padding_repairs.grow(source, 'f', 'mips', [ROWS[0], ('s','sw $ra, 0x24($sp)','sw $ra, 0x18($sp)'), *ROWS[2:]]) + assert not stack_padding_repairs.grow(choices[0], 'f', 'mips', ROWS) + + +def test_iterative_search_can_try_a_measured_missing_frame(): + from decompnet.app.iterative_repairs import targeted_variants + source = 'void f(void) { work(); }' + choices = targeted_variants(source, {'rows':ROWS}, {'name':'f','asm':''}, 'mips', []) + assert any('__repair_stack_padding[8]' in s for s in choices)