Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ local declaration order before random permutation. It keeps original line layout
These hypotheses run through the same compiler and exact judge as agents, in ROM and supported 32-bit imported
projects. They use local CPU, with no model API calls. Rejected candidates do not replace the best saved C.

The same pass can widen a byte/short local when arithmetic or a later conversion suggests truncation, test
candidate-owned callee parameter/return widths, and split or merge bounded integer lifetimes. It also tries
calls inside argument-selecting branches, copies terminal goto leaves, and reorders switch cases with no
fallthrough. Missing no-op zero cases can restore a compiler's original dispatch layout. Shared headers and
project-wide prototypes stay unchanged. These are hypotheses: an improved similarity score is still a draft,
and only a byte-exact result counts as matched. The families also reach iterative/deep search and
`try_local_repairs`; loops, incoming jumps, shadowing and ambiguous declarations limit what is proposed.

For close ROM misses it also tests cached compiler flag alternatives, two probes per pass. Later passes skip
already tested choices and can reach optimization levels as well as debug flags. Only exact results qualify for a
narrow compiler-range change. When automatic compiler changes are disabled, the result becomes a suggestion for
Expand Down
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Roadmap

Bounded integer/call repairs now include evidence-led local widening, candidate-owned callee prototype widths,
integer lifetime splits/merges, call distribution into branches, terminal goto leaves and switch layout variants.
They share the ROM/imported-project judge and existing budgets; a better similarity score remains a draft.
General CFG liveness, fallthrough switch rewriting and shared-header prototype inference remain future work.

What's planned for decompdecomp, roughly in order, and why. Every item keeps the rule that matters most: a match is
byte-identical after linking (ROM mode) or objdiff 100% (fork mode). Nothing here accepts code that only does the
same thing.
Expand Down
203 changes: 203 additions & 0 deletions src/decompnet/app/branch_layout_repairs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Alternative call/leaf and switch layouts, with bounded work and explicit control-flow guards."""
import re

from .integer_repair_support import IDENT, INTEGER, NUMBER, edit, integer, view
from .pattern_repairs import close


def call_statement(text, start):
"""A direct call statement (optionally assigned), with nested argument parentheses kept intact."""
hit = re.match(rf'\s*(?:{IDENT}\s*=\s*)?({IDENT})\s*\(', text[start:])
if not hit or hit[1] in {'if', 'for', 'while', 'switch', 'sizeof', 'return'}:
return None
end = close(text, start+hit.end()-1)
tail = re.match(r'\s*;', text[end:]) if end else None
return (start+hit.start(), end+tail.end()) if tail else None


def flat_assignments(text):
return bool(re.fullmatch(rf'(?:\s*{IDENT}\s*=(?!=)[^;{{}}]*;)+\s*', text)) and not re.search(
r'\b(?:return|goto|break|continue|case|default|for|while|do)\b', text)


def terminal_case(text):
terminal = re.search(r'\b(?:break\s*;|return\b[^;{}]*;|goto\s+'+IDENT+r'\s*;)\s*$', text)
if not terminal or text[:terminal.start()].count('{') != text[:terminal.start()].count('}'):
return False
prefix = text[:terminal.start()].rstrip()
return not prefix or prefix[-1] in ';}'


def controls(part):
"""Braced controls and their ends; an unrecognised scope is a boundary for leaf traversal."""
result = {}
for pos in range(part.opening+1, part.end-1):
if part.clean[pos] != '{':
continue
prefix = part.clean[:pos].rstrip()
kind = 'else' if re.search(r'\belse$', prefix) else ''
if prefix.endswith(')'):
depth = 1
start = len(prefix)-2
while start >= 0 and depth:
depth += (prefix[start] == ')')-(prefix[start] == '(')
start -= 1
hit = re.search(r'\b(if|switch|for|while)\s*$', prefix[:start+1])
kind = hit[1] if hit else ''
if re.search(r'\bdo$', prefix):
kind = 'do'
end = close(part.clean, pos, '{', '}')
if end:
result[end-1] = (pos, kind)
return result


def leaf_transfer(part, start, scopes):
"""Follow closing if arms past their else arms, stopping at one explicit terminal transfer."""
pos = start
while True:
while pos < part.end and part.clean[pos].isspace():
pos += 1
if part.clean[pos:pos+1] != '}':
break
if pos not in scopes or scopes[pos][1] not in {'if', 'else'}:
return None
pos += 1
other = re.match(r'\s*else\s*{', part.clean[pos:])
if other:
end = close(part.clean, pos+other.end()-1, '{', '}')
if not end:
return None
pos = end
elif re.match(r'\s*else\b', part.clean[pos:]):
return None # shortcut: leave unbraced/else-if continuation to a full CFG parser
terminal = re.match(rf'\s*(?:goto\s+({IDENT})|return(?:\s+[^;{{}}]+)?|break)\s*;', part.clean[pos:])
return (pos, pos+terminal.end(), terminal[1]) if terminal else None


def variants(source, name):
part = view(source, name)
if not part:
return []
result = []
clean = part.clean
# Distribute a call following an if/else which only selects scalar arguments. Retain assignments:
# they may still be read afterwards, and evaluating an RHS twice would change its meaning.
for hit in re.finditer(r'\bif\s*\(', part.body):
cond_end = close(clean, part.opening+1+hit.end()-1)
arm = re.match(r'\s*{', clean[cond_end:]) if cond_end else None
if not arm:
continue
left = cond_end+arm.end()-1
left_end = close(clean, left, '{', '}')
other = re.match(r'\s*else\s*{', clean[left_end:]) if left_end else None
if not other:
continue
right = left_end+other.end()-1
right_end = close(clean, right, '{', '}')
if not right_end or not all(flat_assignments(clean[a+1:b-1]) for a, b in [(left, left_end), (right, right_end)]):
continue
call = call_statement(clean, right_end)
if not call:
continue
statement = source[call[0]:call[1]]
result.append(('branch_call', edit(source, [(left_end-1, left_end-1, statement),
(right_end-1, right_end-1, statement), (*call, '')])))
if sum(family == 'branch_call' for family, _ in result) >= 4:
break
# Inline a leaf with its terminal transfer, never a label whose following code would fall through.
# Duplicate local names could change lookup at the new site, so leave those functions alone.
decls = list(re.finditer(rf'(?:^|[;{{}}])\s*(?!goto\b|return\b|break\b|continue\b)'
rf'(?:{IDENT}\s+)+\**\s*({IDENT})\s*(?=[;=,\[])', part.body))
declarations = [decl[1] for decl in decls]
nested = any(part.body[:decl.start(1)].count('{') != part.body[:decl.start(1)].count('}') for decl in decls)
if not nested and len(declarations) == len(set(declarations)):
scopes = controls(part)
# Unbraced loops can change a copied break's destination too; don't reason through them.
unbraced = False
for loop in re.finditer(r'\b(?:for|while)\s*\(', part.body):
loop_start = part.opening+1+loop.start()
if loop[0].startswith('while') and any(kind == 'do' and ending < loop_start
and not clean[ending+1:loop_start].strip() for ending, (_, kind) in scopes.items()):
continue # the latch of a braced do/while is not a second, unbraced loop
loop_end = close(clean, part.opening+1+loop.end()-1)
if loop_end and not re.match(r'\s*{', clean[loop_end:]):
unbraced = True

def break_scope(pos):
enclosing = [(opening, ending) for ending, (opening, kind) in scopes.items()
if opening < pos < ending and kind in {'switch', 'for', 'while', 'do'}]
return max(enclosing, default=None)

for label in re.finditer(rf'\b({IDENT})\s*:(?!:)', part.body):
start = part.opening+1+label.end()
call = call_statement(clean, start)
if not call:
continue
terminal = leaf_transfer(part, call[1], scopes)
if not terminal:
continue
transfer_start, end, destination = terminal
leaf = source[start:call[1]]+source[transfer_start:end]
if destination == label[1]:
continue
for jump in re.finditer(rf'\bgoto\s+{re.escape(label[1])}\s*;', part.body):
pos = part.opening+1+jump.start()
if start <= pos < end:
continue
if re.search(r'\bbreak\b', clean[transfer_start:end]) and (
unbraced or break_scope(pos) is None or break_scope(pos) != break_scope(start)):
continue
result.append(('goto_leaf', edit(source, [(pos, part.opening+1+jump.end(), '{'+leaf+'}')])) )
if sum(family == 'goto_leaf' for family, _ in result) >= 4:
break
if sum(family == 'goto_leaf' for family, _ in result) >= 4:
break
for switch in re.finditer(r'\bswitch\s*\(', part.body):
condition = close(clean, part.opening+1+switch.end()-1)
arm = re.match(r'\s*{', clean[condition:]) if condition else None
if not arm:
continue
opening = condition+arm.end()-1
end = close(clean, opening, '{', '}')
if not end:
continue
inside = clean[opening+1:end-1]
labels = [hit for hit in re.finditer(rf'\b(?:case\s+(-?{NUMBER})|default)\s*:', inside)
if inside[:hit.start()].count('{') == inside[:hit.start()].count('}')]
if not labels or inside[:labels[0].start()].strip():
continue
# Reject non-literal/range labels, not merely the subset our regex understands.
all_labels = [hit for hit in re.finditer(r'\b(?:case\b[^:]*|default)\s*:', inside)
if inside[:hit.start()].count('{') == inside[:hit.start()].count('}')]
if len(labels) != len(all_labels):
continue
if not any(label[1] is None or integer(label[1].lstrip('-')) == 0 for label in labels):
result.append(('switch_layout', edit(source, [(opening+1, opening+1, '\ncase 0: break;\n')])))
starts = []
pending = labels[0].start()
valid = True
for index, label in enumerate(labels):
stop = labels[index+1].start() if index+1 < len(labels) else len(inside)
code = inside[label.end():stop]
if not code.strip():
continue # grouped labels travel with their one body
# Only block-scoped declarations can move with a case. No fallthrough or cross-case labels.
if re.search(rf'\b(?:{INTEGER}|void|float|double)\s*\**\s*{IDENT}', code):
valid = False
break
if not terminal_case(code):
valid = False
break
starts.append((pending, stop))
pending = stop
if not valid or not 2 <= len(starts) <= 6:
continue
chunks = [source[opening+1+a:opening+1+b] for a, b in starts]
orders = [list(range(rotation, len(chunks)))+list(range(rotation)) for rotation in range(1, len(chunks))]
orders += [list(reversed(range(len(chunks))))]
for order in orders[:6]:
result.append(('switch_layout', edit(source, [(opening+1+starts[0][0], opening+1+starts[-1][1],
''.join(chunks[index] for index in order))])))
break # bounded per function, not factorial permutations of every nested switch
return list(dict.fromkeys(result))
60 changes: 60 additions & 0 deletions src/decompnet/app/callee_prototype_repairs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Repair candidate-owned scalar callee prototypes without editing a project's shared declarations.

Wrong short formals can add conversions at every call. Masked results can also reveal a wrongly inferred
return width. Unknown, conditional, variadic and indirect declarations remain with the real compiler.
"""
import re

from ..forks.csource import find
from .integer_repair_support import INTEGER, calls, edit, split_args, view
from .repair_preflight import _PROTO

NARROW = {'s8', 'u8', 's16', 'u16', 'char', 'short', 'signed char', 'unsigned char', 'signed short', 'unsigned short'}


def variants(source, name):
part = view(source, name)
if not part or re.search(r'^\s*#', part.clean, re.M):
return []
prototypes = list(_PROTO.finditer(part.clean[:part.opening]))
result = []
for proto in prototypes:
callee = proto[1]
if callee == name or sum(p[1] == callee for p in prototypes) != 1 or find(source, callee):
continue
sites = list(calls(part, callee))
if not sites or not proto[2].strip() or '...' in proto[2]:
continue
# A local object/parameter can shadow this function name.
without_proto = part.clean[:proto.start()]+part.clean[proto.end():]
if re.search(rf'\b{re.escape(callee)}\b(?!\s*\()', without_proto):
continue
params = split_args(proto[2])
for start, end in params:
param = proto[2][start:end]
match = re.fullmatch(rf'\s*({INTEGER})(?:\s+[A-Za-z_]\w*)?\s*', param)
if not match or ' '.join(match[1].split()) not in NARROW:
continue
offset = proto.start(2)+start
result.append(('callee_parameter_width', edit(source, [(offset+match.start(1), offset+match.end(1), 'int')])))
if len(result) >= 6:
break
# Infer return-width choices only from a captured direct result subsequently masked in this caller.
returns = re.match(rf'\s*(?:extern\s+)?({INTEGER})\s+{re.escape(callee)}\s*\(', proto[0])
if returns and ' '.join(returns[1].split()) in {'s32', 'u32', 'int', 'signed int', 'unsigned int'}:
for start, _ in sites:
# Call whitespace can vary; locate the assignment ending at the callee token instead.
assignment = re.search(rf'\b([A-Za-z_]\w*)\s*=\s*{re.escape(callee)}\s*\($', part.clean[part.opening+1:start])
if not assignment:
continue
variable = re.escape(assignment[1])
masks = re.findall(rf'\b{variable}\s*&\s*0x(FF|FFFF)\b', part.body, re.I)
for mask in dict.fromkeys(masks):
for typ in (('unsigned char', 'signed char') if len(mask) == 2 else ('unsigned short', 'short')):
offset = proto.start()+returns.start(1)
result.append(('callee_return_width', edit(source, [(offset, proto.start()+returns.end(1), typ)])))
if masks:
break
if len(result) >= 10:
break
return list(dict.fromkeys(result))[:10]
11 changes: 11 additions & 0 deletions src/decompnet/app/draft_repairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
from .. import m2cfix, seeds
from ..forks.csource import _blank, find
from . import (
branch_layout_repairs,
branch_locals,
callee_prototype_repairs,
captured_repairs,
expression_repairs,
integer_lifetime_repairs,
lifetime_repairs,
load_pair_repairs,
local_widening_repairs,
local_width_repairs,
mmio_repairs,
near_repairs,
Expand Down Expand Up @@ -74,6 +78,13 @@ def variants(source: str, name: str, arch: str, asm: str = '', flags=(), labels=
for text in local_width_repairs.variants(source, name):
proposals.append(text)
labels[text] = 'local_width'
for text in local_widening_repairs.variants(source, name):
proposals.append(text)
labels[text] = 'local_widening'
for module in (callee_prototype_repairs, integer_lifetime_repairs, branch_layout_repairs):
for family, text in module.variants(source, name):
proposals.append(text)
labels[text] = family
for text in pointer_cast_repairs.variants(source, name, arch):
proposals.append(text)
labels[text] = 'pointer_cast_inline'
Expand Down
7 changes: 7 additions & 0 deletions src/decompnet/app/guide/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ Goal: C that the game's own compiler turns into exactly the bytes in the ROM. No
## Priorities
Get the instruction count and control flow right first, then operands, then register allocation.
Plausible original code beats contortions: games were written by people, quickly.

Before sending many hand-written variants, use `try_local_repairs` on the current revision. Its bounded
families include `local_widening`, `callee_parameter_width`, `callee_return_width`, `split_integer_lifetime`,
`merge_integer_lifetimes`, `branch_call`, `goto_leaf` and `switch_layout`. They can test lost coordinate bits,
unwanted call conversions, integer register lifetimes and alternative dispatch layouts without model-written
source variants. Candidate-owned prototypes change only in that candidate; confirm a discovered signature
before suggesting a shared header edit. A lower score is useful evidence, not a proof of correctness.
Loading
Loading