PREUVE / ORACLE / EXÉCUTION / CERTIFICATION

Ingénierie de reconstruction

Reconstruire un logiciel abandonné, c’est transformer une preuve fragmentaire en système mesurable, puis prouver où le nouveau runtime rejoint l’original, où il s’en écarte, et pourquoi.

Méthode
Guidée par la preuve
S’applique à
Abomination · Rebel Moon Rising
Preuve
Gates à hash exact
Signée par
Des machines, puis des personnes

Deux projets de ce portefeuille reconstruisent des logiciels que plus personne ne maintient : un jeu tactique de 1999 et un jeu de tir à la première personne de 1997. Ils visent des moteurs différents, décodent des formats différents et répondent à des contraintes différentes. Ils partagent une méthode, et cette page est cette méthode écrite — parce qu’une méthode qui n’existe que sous forme d’habitude ne peut être ni critiquée, ni transmise, ni améliorée.

Le pipeline

À lire comme un ensemble de frontières à sens unique plutôt que comme un calendrier. La preuve ne devient jamais modifiable. L’extraction n’invente jamais. La classification ne se promeut jamais elle-même. La simulation ne dessine jamais. La certification ne signe jamais la gate humaine. Chaque flèche est un endroit où quelque chose a le droit de perdre de l’information et l’interdiction d’en gagner.

1 — Preuve immuable et inventaire

Le premier artefact n’est pas un fichier décodé. C’est une copie en lecture seule du média d’origine, plus un inventaire de hash de chacun de ses octets. Tout l’aval cite cet inventaire, ce qui permet de vérifier une affirmation faite six mois plus tard contre les mêmes octets, et non contre un répertoire que quelqu’un a modifié entre-temps. Lorsque le média d’origine est propriétaire, il reste sur la machine qui le détient : la reconstruction livre des outils et un runtime, jamais les assets d’autrui.

2 — Extraction statique à une frontière contrôlée

Un parseur pour un format que personne n’a documenté est un argument sur le sens des octets, et un argument doit échouer bruyamment quand il est faux. Chaque lecture est bornée, chaque refus nomme l’offset auquel il a refusé, et aucun accesseur ne contourne le contrôle. Un parseur qui remplit ou devine en silence à travers la corruption ne produit pas des données partielles : il produit une fiction assurée.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/reader.pyPython
from dataclasses import dataclass

class FormatError(ValueError):
    pass

@dataclass
class Reader:
    data: bytes
    offset: int = 0

    def take(self, size: int) -> bytes:
        end = self.offset + size
        if size < 0 or end > len(self.data):
            raise FormatError(
                f"read {size} bytes at {self.offset}, file={len(self.data)}"
            )
        chunk = self.data[self.offset:end]
        self.offset = end
        return chunk

    def u16le(self) -> int:
        return int.from_bytes(self.take(2), "little", signed=False)

Le message d’erreur est tout l’enjeu : il porte la taille demandée, l’offset auquel elle l’a été et la taille du fichier. Un échec de format devient une coordonnée que l’on peut aller regarder.

3 — Une représentation normalisée qui garde ses inconnues

L’extraction produit une représentation intermédiaire, non un asset de moteur cible. Convertir directement d’un format d’origine vers le format natif d’un moteur écrase deux décisions distinctes — ce que signifient les données, et comment ce moteur les exprime — en une seule étape irréversible. Les tenir séparées permet une décision de moteur ultérieure, et permet de rejouer un correctif de parseur sans refaire la conversion à la main.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/import_result.pyPython
from dataclasses import dataclass, field
from typing import Generic, TypeVar

T = TypeVar("T")

@dataclass(frozen=True)
class ImportProvenance:
    source_sha256: str
    parser_version: str
    schema_version: str
    evidence_ids: tuple[str, ...]

@dataclass(frozen=True)
class ImportResult(Generic[T]):
    value: T
    provenance: ImportProvenance
    warnings: tuple[str, ...] = ()
    unknown_fields: dict[str, bytes] = field(default_factory=dict)

La matière inconnue est conservée plutôt que jetée, et la version du parseur voyage avec la valeur. Un parseur ultérieur peut reprendre ce qu’un parseur antérieur ne savait pas expliquer, sans reconstruire le fichier d’origine de mémoire.

4 — La taxonomie de preuve

La plupart des disputes de reconstruction sont en réalité des disputes de grade. Un nombre lu dans un fichier de données, un nombre mesuré sur un original en marche, un nombre calculé à partir des deux premiers, un nombre qui explique au mieux la preuve, un nombre que personne n’a encore testé, un nombre choisi délibérément, un nombre imposé par le moteur cible et un jugement que nul ne peut automatiser sont huit choses différentes. Les confondre, c’est ainsi qu’une reconstruction devient un hommage sans que personne ne l’ait décidé.

  • Preuve source — les octets d’origine, hachés et en lecture seule.
  • Observation — ce que le runtime d’origine a visiblement fait, sous un scénario consigné.
  • Mesure — une procédure répétable, avec unités, entrées et sorties enregistrées.
  • Dérivation — calculée déterministement depuis des valeurs observées ou mesurées.
  • Inférence — la meilleure explication actuelle de la preuve, non observée elle-même.
  • Hypothèse — une proposition testable, sans preuve à ce jour.
  • Design — un choix délibéré de la reconstruction, sans affirmation sur l’original.
  • Concession de compatibilité — un écart connu, imposé par le moteur ou la plateforme cible.
  • Verdict humain — une décision esthétique, d’usage ou d’acceptation qu’aucune automatisation ne signe.
CONTRAT D’ARCHITECTURE PUBLICarchitecture/evidence.pyPython
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path

class EvidenceClass(StrEnum):
    OBSERVED = "observed"
    MEASURED = "measured"
    DERIVED = "derived"
    INFERRED = "inferred"
    HYPOTHESIS = "hypothesis"
    DESIGN = "design"
    COMPATIBILITY = "compatibility"
    HUMAN_VERDICT = "human_verdict"

@dataclass(frozen=True)
class EvidenceClaim:
    claim_id: str
    classification: EvidenceClass
    statement: str
    source_path: Path | None
    source_sha256: str | None
    scenario_id: str | None
    confidence: float | None

La classification est une donnée, non une prose enfouie dans un rapport. Un build peut alors refuser de promouvoir une valeur de design en affirmation de fidélité, et un lecteur distingue « mesuré à 36 Hz » de « conçu pour en donner l’impression ».

5 — Un oracle est un système de mesure

« Nous avons comparé à l’original » n’est pas une preuve. Un oracle est un instrument contrôlé : un scénario nommé, une graine, un ensemble énuméré d’actions légales, un journal de commandes avec numéros de séquence et ticks, un instantané portant un hash d’état après chaque commande, un état d’échec défini, et la répétabilité depuis un profil propre. Sans cela, deux exécutions de l’original sont deux anecdotes.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/oracle.pyPython
from dataclasses import dataclass
from typing import Any, Protocol

@dataclass(frozen=True)
class OracleCommand:
    sequence: int
    tick: int
    name: str
    arguments: dict[str, Any]

@dataclass(frozen=True)
class OracleSnapshot:
    tick: int
    scene: str
    state_hash: str
    legal_commands: tuple[str, ...]
    values: dict[str, Any]

class OracleAdapter(Protocol):
    def handshake(self) -> dict[str, str]: ...
    def reset(self, scenario_id: str, seed: int) -> OracleSnapshot: ...
    def execute(self, command: OracleCommand) -> OracleSnapshot: ...
    def close(self) -> None: ...

L’adaptateur expose cycle de vie, scénario, graine, actions légales et instantanés. La seule capture d’écran permet une comparaison visuelle et n’est pas un oracle d’état ; un adaptateur incapable d’énumérer les commandes légales ne peut pas non plus piloter une traversée en autoplay.

En pratique, le transport est délibérément ennuyeux. Un message JSON par ligne pour chaque commande, un pour chaque réponse : une session devient un fichier que l’on peut comparer, rejouer et joindre à un rapport.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/oracle-session.jsonlJSON Lines
{"op":"hello","protocol":1,"client":"pomegranate-harness"}
{"ok":true,"engine":"reference","build":"1.1","capabilities":["reset","step","snapshot"]}
{"op":"reset","scenario":"map01-north-east","seed":731}
{"ok":true,"tick":0,"state_hash":"c3f55c...","legal":["move","turn","wait"]}
{"op":"step","sequence":1,"tick":0,"command":"move","args":{"x":38,"y":29}}
{"ok":true,"tick":36,"events":[{"type":"projectile_spawn","record":9}]}

Une illustration de protocole, non une trace littérale d’un projet. C’est la forme qui compte : la poignée de main déclare les capacités, la réinitialisation nomme un scénario et une graine, et chaque réponse porte le tick auquel elle a abouti.

6 — Un cœur déterministe incapable de dessiner

La simulation fait avancer l’état depuis une trame d’entrée et retourne un nouvel état et les événements survenus. Elle n’a ni moteur de rendu, ni fenêtre, ni horloge murale, ni dérive flottante là où des entiers suffisent. Ce n’est pas de la pureté pour la pureté : un cœur incapable de dessiner peut être exécuté des milliers de fois sans affichage contre des attentes issues de l’oracle, et une divergence en son sein est un bug sémantique, non un bug graphique déguisé.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/simulation.pyPython
from dataclasses import dataclass

@dataclass(frozen=True)
class InputFrame:
    tick: int
    actions: tuple[str, ...]

@dataclass(frozen=True)
class StepResult:
    state: "GameState"
    events: tuple["DomainEvent", ...]

class Simulation:
    def step(self, state: "GameState", frame: InputFrame) -> StepResult:
        next_state = self._apply_actions(state, frame.actions)
        next_state = self._advance_ai(next_state)
        next_state = self._resolve_combat(next_state)
        return StepResult(next_state, self._emit_events(state, next_state))

Le rendu est absent du contrat, et le pas est total : le même état et la même trame produisent toujours le même résultat. Tout ce dont la couche de présentation a besoin arrive sous forme d’événements, sans jamais aller puiser dans l’état.

7 — Le hachage d’état exige une canonicalisation déclarée

Un hash d’état n’a de sens que si la sérialisation qu’il hache est canonique et si les champs volatils sont nommés. Hacher un dump d’objet sans contrôler l’ordre des clés, le formatage et les valeurs dépendantes de l’horloge ne produit pas du déterminisme : cela produit du non-déterminisme exprimé en hexadécimal, considérablement plus pénible à déboguer que le problème initial.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/state_hash.pyPython
import hashlib
import json
from collections.abc import Mapping

VOLATILE_KEYS = {"render_time_ms", "wall_clock", "window_handle"}

def canonical_state_hash(state: Mapping[str, object]) -> str:
    stable = {
        key: value
        for key, value in state.items()
        if key not in VOLATILE_KEYS
    }
    payload = json.dumps(
        stable,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()

L’ensemble volatil est une liste explicite et relisible, non un filtre que quelqu’un pense à appliquer. Ajouter un champ à l’état sans décider s’il est volatil devient alors un changement visible, non une source silencieuse de rejeux instables.

8 — Le rejeu localise la première divergence

Un rejeu qui annonce « échec » à la fin d’une trace ne sert presque à rien. Ce qui compte est le premier tick auquel la reconstruction et la trace enregistrée cessent de s’accorder : c’est là que commence la dérive sémantique, et tout ce qui suit en est la conséquence, non la cause.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/replay.pyPython
from dataclasses import dataclass

@dataclass(frozen=True)
class Divergence:
    tick: int
    expected_hash: str
    actual_hash: str
    command_sequence: int


def replay(session: "Session", trace: "Trace") -> Divergence | None:
    for command, expected in trace.frames:
        actual = session.execute(command)
        if actual.state_hash != expected.state_hash:
            return Divergence(
                tick=actual.tick,
                expected_hash=expected.state_hash,
                actual_hash=actual.state_hash,
                command_sequence=command.sequence,
            )
    return None

La valeur de retour est le premier écart, ou rien du tout. Les allers-retours sauvegarde/rechargement emploient la même machinerie : recharger dans un processus neuf, poursuivre la trace, et toute divergence est un bug de persistance capturé par le même détecteur.

9 — L’autoplay explore les actions légales, pas des entrées aléatoires

La traversée de campagne est un problème de graphe. Partant d’un instantané, le pilote demande ce que le modèle ou l’adaptateur déclare légal, prévisualise chacune de ces commandes et met en file les résultats qui ne sont ni invalides ni des échecs terminaux — en visitant les états par leur hash, pour qu’une boucle ne soit visitée qu’une fois. Le matraquage d’entrées aléatoires trouve des plantages ; la traversée par actions légales trouve du contenu inatteignable, des objectifs morts et des états dont la reconstruction peut entrer sans pouvoir sortir.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/autoplay.pyPython
from collections import deque


def traverse(initial: "Snapshot", driver: "Driver") -> set[str]:
    queue = deque([initial])
    visited: set[str] = set()

    while queue:
        state = queue.popleft()
        if state.state_hash in visited:
            continue
        visited.add(state.state_hash)

        for command in driver.legal_commands(state):
            result = driver.preview(state, command)
            if not result.invalid and not result.terminal_failure:
                queue.append(result.snapshot)

    return visited

Une traversée réelle exige une profondeur bornée, des heuristiques de scénario et une abstraction d’état, sans quoi la frontière explose. Le principe survit au bornage : l’exploration part des commandes légales fournies par le modèle, si bien que la couverture parle du jeu plutôt que du fuzzer.

10 — La certification lie les gates à un artefact exact

La preuve appartient à un hash, non à un projet. Une gate franchie contre un paquet ne dit rien du paquet suivant qui lui ressemble : l’identité du candidat et l’état des gates voyagent donc ensemble dans un même manifeste, et ce manifeste porte ses limites dans le même document que ses réussites.

CONTRAT D’ARCHITECTURE PUBLICarchitecture/certification.yamlYAML
candidate:
  commit: 0123456789abcdef
  package_sha256: 9a8b7c...
  engine:
    name: reference-engine
    version: 5.0.0

gates:
  parser_fixtures: pass
  clean_build: pass
  blank_profile_boot: pass
  save_load_roundtrip: pass
  replay_state_hash: pass
  campaign_traversal: partial
  linux_native: pass
  windows_native: pending
  human_visual_acceptance: pending

limits:
  - "MAP01 certified; later maps are not implied"
  - "Compatibility-layer smoke is not native certification"

Notez les valeurs qui ne sont pas « pass ». Partiel et en attente sont des états de plein droit, et la liste de limites existe pour qu’un lecteur ultérieur ne prenne pas une certification cadrée pour une certification générale.

11 — Gates automatisées et gates humaines sont des objets différents

Une machine peut prouver que les parseurs refusent une entrée malformée, qu’un build est octet-identique deux fois, qu’un profil vierge démarre, qu’une sauvegarde fait l’aller-retour par un processus neuf, qu’un rejeu produit les hashs d’état enregistrés, qu’une traversée atteint les objectifs atteignables, et qu’un paquet ne contient rien d’indu. Une machine ne peut pas décider que le résultat a la bonne allure, se joue bien ou mérite d’être publié. Ces gates restent ouvertes, visiblement, jusqu’à ce qu’une personne les signe — et un projet qui laisse discrètement l’automatisation les signer a cessé de mesurer quoi que ce soit.

12 — Droits et provenance sont des gates, pas de la paperasse

Deux règles gouvernent chaque reconstruction de ce portefeuille. Le média d’origine n’est jamais redistribué : lorsqu’une reconstruction a besoin de données propriétaires, le joueur fournit un média qu’il détient légalement et un importateur le convertit localement. Et aucune protection n’est jamais contournée : lorsqu’un runtime protégé ne peut être observé licitement, le constat est consigné comme bloqué et l’affirmation de parité correspondante n’est pas faite. Une gate bloquée en public vaut mieux qu’une affirmation invérifiable.

13 — Les modes d’échec que cette méthode existe pour empêcher

  • L’hommage involontaire : assez de décisions de design s’accumulent, chacune défendable isolément, pour que le résultat s’inspire de l’original au lieu d’en être un. Cela s’empêche en graduant chaque valeur, non par le goût.
  • Le parseur assuré : l’entrée malformée est remplie ou devinée, et la reconstruction se bâtit sur une fiction qui n’a jamais levé d’erreur.
  • L’oracle anecdotique : quelqu’un a lancé l’original une fois et se rappelle de quoi cela avait l’air. Sans scénarios, graines et instantanés, c’est un récit.
  • Le non-déterminisme hexadécimal : un hash d’état sur un dump non canonicalisé, qui convertit l’instabilité en diff illisible.
  • L’héritage de preuve : des gates franchies sur un build antérieur sont discrètement portées au crédit d’un build ultérieur parce que les deux se ressemblent.
  • La dérive de vocabulaire : une passe automatisée est décrite comme une acceptation, ou un candidat empaqueté comme une sortie, et les mots cessent de porter de l’information.