Skip to content

Latest commit

 

History

History
440 lines (378 loc) · 26 KB

File metadata and controls

440 lines (378 loc) · 26 KB

Contributing

Contributions are welcome, written by hand or with an agent. This file is the operating manual: what to read before changing anything, which gates have to be green, how to read their output, and what makes a pull request land on the first review instead of the third.

It states no rule twice. Every rule has exactly one home, and this file points at it.

Read these first

File What it owns
README.md The goal, the requirements, what plays, and where everything else lives.
docs/STATUS.md What is implemented, what is not yet, what never will be, and the divergences kept on purpose.
docs/CONVENTIONS.md Every code rule: module layout, visibility, data-structure discipline, unsafe discipline, doc-comment shape, dependencies.
docs/ARCHITECTURE.md The boundary contract: thunk versus command, stable backing for pointers the unix side dereferences, typed wire values, labelling Metal objects, the threading model, the perf counters and how to read them.
unix/conformance/CONFORMANCE.md The conformance suite: how the runner and the baseline work, what each classification means, and the current per-site audit with its rationales.
windows/tests/COVERAGE.md What the end-to-end suite covers, and the stubs whose contract a test pins on purpose.
mtld3d.conf Every runtime option, its default, and a short why.

Read the conformance document before changing anything in the render, state or shader-emission path. It is where the reasoning behind the current behaviour lives, and a change that looks like a fix is often a divergence that was measured and kept.

The gates

Two commands, both green before you commit:

  • make check is cargo fmt --check, clippy with nursery and pedantic, make audit, and make doc. Only the check legs deny warnings, so a plain cargo clippy in an editor reports without failing. Each audit finding names the section of docs/CONVENTIONS.md behind it; read that section rather than pattern-matching your way past the grep.
  • make test is the host-native unit tests plus the end-to-end suite under Wine, one leg per PE architecture. Conformance is not part of it, on purpose: many of its checks fail by design, so it gates on a regression against a baseline instead of on zero failures.

make fmt uses nightly rustfmt. If a toolchain bump reformats files you never touched, that churn is its own pull request, not a hand-revert and not a passenger in yours.

Every test leg installs the build into the Wine tree WINE_SDK names (and into WINE_INSTALL_DIR when set) before it runs, so two checkouts testing at once overwrite each other's d3d9.dll and mtld3d.so, and a game launched from that tree meanwhile runs whichever build landed last. make test ISOLATED=1 avoids both: it clones the SDK and the ambient prefix once into .wine-isolated/ inside the checkout (APFS clones, so neither costs space or a prefix boot) and points the tools, the install and the prefix at the clones. Each clone is one clonefile(2) on the directory (the Makefile's clone_tree), so it costs a call rather than a walk of the file count, and falls back to cp -c -R and then to cp -R when the source sits on another volume or on a volume that is not APFS. Use it whenever another worktree may be testing or a game is running; a plain make install still targets the shared trees on purpose, since that is how the game gets a build. The clones and the persistent wineserver of the private prefix stay behind for the next run; make clean-isolated takes down the ones in the checkout you are in, and make clean-isolated-orphans the ones a removed checkout left behind. Either one ends the whole Wine session rather than its server alone: the service processes a prefix keeps (services.exe, the two winedevice.exe, plugplay.exe, svchost.exe, rpcss.exe) outlive a server that is merely signalled, and nothing in their name says which checkout they belong to, so what is left over after the server is gone is found by the paths it holds open. clean-isolated-orphans finds the environments three ways: the clones beside the checkouts, the record every isolated checkout writes into the directory the worktrees share, and any process still running out of one. So neither a removed directory, nor a checkout kept somewhere else, nor a reboot that took the servers hides one. A directory that is still a checkout is left alone there: its environment may be mid-run, and it is that checkout's own make clean-isolated to take down. A failing run whose log shows a d3d9.dll v stamp that is not your checkout's is that collision, not a regression.

Both agent runners configured in this tree already print the conventions digest at session start and run scripts/audit.sh --file after every edit, so a violation surfaces while you write rather than at commit time. The digest at .claude/conventions-digest.md is generated: regenerate it in the same change that touches docs/CONVENTIONS.md.

Reading a test run

The end-to-end suite is four test binaries per architecture, and the runner in unix/e2e runs each one once under Wine with every test of the binary on JOBS threads of that process (four at a time by default; the Makefile says what that assumes of the Wine it runs under). It prints one PASS/FAIL/SKIP line per test and a summary that counts every test, so the summary is the thing to read; a failure is fatal to the default run (FAIL_FAST=0 reports the whole suite). One trap remains: a pipeline reports the last stage's status, so make test | tee log returns the exit code of tee. Capture with a plain redirect, make test > out.log 2>&1, and judge the run by the runner's summary on both architectures. mtld3d's log of each test process is a file, <binary>-<pid>.log under mtld3d-logs next to the test executable in windows/target, one per process, so one file carries the whole suite's log.

A process that ends cleanly is still checked against libtest's own account. Its test result: line counts the results it printed, and a runner tally short of that count means a result never arrived, whatever tore it off the pipe. The note is libtest counted <n> results and the runner read <m>; no outcome for: <names>, the named tests run again in a fresh process, and the summary's total therefore never falls short of the set the run was asked for. A second process that reports none of them again fails them rather than looping.

A process that ends with tests unaccounted for leaves its whole stderr in the same directory as <binary>-<pid>.stderr, and every line the runner prints about that process names the file. What it prints inline is the last fifteen lines the process itself wrote: Wine's fixme lines and everything its dbghelp channel prints are left out, because dozens of them surround each backtrace and a raw tail rarely reaches back past them to the message that says what failed. The layer's own log of that process is the other account, and the only one of a death the layer's crash handler ended: its fatal banner, registers and stack go there and never to stderr. The runner moves that log to <binary>-<pid>.layer-log beside the stderr, because the layer keeps only its ten newest logs and the next run would remove it, and quotes it in the same note from the banner on when there is one. Every test process runs under Apple's Main Thread Checker (debug.mainThreadChecker in the suite's config, docs/ARCHITECTURE.md says how), so a kept .stderr that carries a Main Thread Checker: UI API called on a background thread: line names the AppKit call the layer made off the main thread and the thread that made it; that call is the failure, whatever the process printed after it.

Process cleanup has one two-second budget from the first termination or cleanup attempt, including signal-error retries and the final reap. The leader stays unreaped until the last group signal. If cleanup cannot establish exit and reap, the runner reports the unresolved PID and exits immediately with infrastructure code 2. A survivor may still be running: macOS adopts the remaining children and owns their eventual reap. No background thread survives the runner to do that work. This bounds the runner's wait/retry loops, subject to scheduling, syscalls and diagnostic output; the kernel can also delay process exit while closing file descriptors. No further binary runs after this failure.

An explicit driver GPU-hang report ends the leg with exit code 3 and no verdict, even under FAIL_FAST=0 or when the process itself exits cleanly. The runner watches stderr while the process runs, checks the layer log before it can launch another process, and keeps both accounts of the initiating process. Assertions after that report are not measurements of the source: the later results may reflect the hosted GPU's failed state.

When every test is accounted for but the process ends abnormally, the runner keeps its full captured stdout, stderr and exit status together in <binary>-<pid>.process-log and names it in the note. This preserves the existing test verdicts and does not retry completed tests. The streams are captured decoded text, not a byte-exact pipe recording. A retention error is reported in the note without changing those verdicts. Clean successful and expected self-exiting processes create no such file. As with stderr, the newest ten bundles remain in the selected log directory.

In CI every end-to-end leg uploads all four kinds of file as its e2e-logs-<image>-<arch> artifact on every run, kept fourteen days; the directory holds the ten newest of each, so a leg that restarted more than ten processes hands back its last ten. They are the layer's side of a red leg, which the job log lacks: when one image reads back black on test after test while its sibling legs are green, re-run the failed jobs first (gh run rerun <run-id> --failed, which lands on a fresh machine) and read the command buffer errors in the artifact's log, since a hosted runner's GPU can fail that way with no hang line in the job log and nothing else tells it from a regression. A CreateBackbuffer failure on the Intel image is read the same way: its unix line names the request and the device, and a sane request (the window's size, BGRA8Unorm, a sample count the device answered for) that newTextureWithDescriptor refused on that image's GPU, the paravirtual AppleParavirtGPUMetal, is the same runner fault, so re-run the failed jobs. A line naming a zero dimension or a null handle is the layer's own bug.

Two things are worth knowing when a test process looks wrong. d3d9.dll terminates the process from its DLL_PROCESS_DETACH once a device exists (it cannot survive the allocator's thread-local teardown on Wine's 1 MB main-thread stack), so a test binary's exit status is whatever that TerminateProcess carries: the status the process asked to exit with, and 0 for a process that never asked. The harness's panic hook (windows/tests/src/win32.rs) does not wait for libtest to reach its own exit and terminates with libtest's failure code at the first failed assertion, after the default hook has printed the report that names the test. The tests in flight go down with the process: the runner marks the named test failed and runs the rest again in a fresh process, and a crash or a hang (no result for TIMEOUT seconds) is charged the same way, through a one-thread re-run of the tests that were in flight when nothing names the culprit. So a failure costs one result and one extra process, and the processes count in the summary says how many the run took: eight is a clean make test.

Explicit test selections, including recovery rounds and filtered initial runs, are split into batches that fit the Windows command-line limit. The budget counts UTF-16 units after quoting, with flags, separators, the terminator and an allowance for Wine's executable-path mapping. Every deferred name keeps its intended thread width, so a serial recovery stays serial across batches. Each batch is an actual process with its own summary and retained failure logs; the original failure remains failed. Fail-fast and a GPU-hang report stop before later batches. A single name that cannot fit reports a runner error instead of repeatedly launching an impossible command.

The harness defaults to hidden borderless windows. Wine builds a framed window's title bar and controls on the AppKit main thread, so creating and destroying them for every rendering test serializes a parallel run. Tests of window-style changes use HarnessConfig::window_style with WindowStyle::Framed, as does the window-lifecycle stress test. Keep that choice explicit when a test needs the non-client frame.

Which suite is right when they disagree

Wine's d3d9 test suite is the spec oracle. The end-to-end suite is our own regression harness, so its assertions can encode our past bugs rather than D3D9's behaviour.

When a conformance-improving change breaks an end-to-end test, the first question is whether the assertion is wrong, not whether the change should be reverted. If the new behaviour is what D3D9 does, update or delete the assertion and keep the fix. Revert only when the change itself is wrong, for example when it rejects an operation that is valid.

Speed, conformance, and divergences

Speed is the goal and conformance serves it, so a divergence that buys frame time and breaks no game is allowed to stay. What is not allowed is a silent one.

A kept divergence is a decision with three obligations: it gets its line in the kept-divergence list of docs/STATUS.md, its rationale goes in the Kept divergences section of CONFORMANCE.md (plus the cluster prose where Wine's suite has a site for it), and where a knob makes sense it is revertible from mtld3d.conf. "It matches what we ship today" is not a justification for a change, because what we ship today may itself be the divergence. Prove a claim about observable behaviour with a test that fails before the change and passes after, and say in the pull request which way it went.

Reference implementations

Before inventing a render-state heuristic, a per-shader allowlist, or any game-shaped special case, read what an established implementation does. DXVK's src/d3d9/ (including its shader translator under dxso/) is the correctness reference and has been hardened against thousands of D3D9 titles; dxmt is the D3D9-on-Metal reference for the workloads it has been validated against; Wine's own wined3d is the baseline that runs on the same machine, so a behaviour we get wrong and it gets right is a regression by definition.

If one of them ships clean where we do not, the gap is usually a structural feature we have not ported, not a quirk of one game. Port the structure. When our architecture forces a deviation, say so explicitly in the pull request instead of quietly simplifying.

Working on conformance

make conformance                       # both architectures, diffed against the baseline
make conformance-i686                  # one architecture, one runner process
make conformance-isolate ONLY=visual ARCH=x86_64 REPEAT=1
make conformance-isolate ONLY=device ARCH=i686 REPEAT=20 VARIANT=intel LOG=debug
make conformance-baseline-i686         # re-record this architecture's entries

The rules that are easy to get wrong:

  • Classifications live only in CONFORMANCE.md, on its Sites: lines. baseline.txt is machine-owned counts and crash state; the parser rejects class tokens there.
  • A classification records the nature of a divergence, never its difficulty or how much a game cares. A hard-to-fix real defect is still real.
  • Re-record the baseline in the same change as the fix that moves the counts, and check the diff: a re-record drops flaky-pinned sites that happened to read zero in that run.
  • Derive the reason for a failing site from the upstream test source and from the raw actual-versus-expected values, which MTLD3D_CONFORMANCE_RAW_DIR=<dir> keeps. A site name is not a description of what the test exercises.
  • Run gating runs with clean shader caches, and never re-record prefix drift: the Makefile pins the prefix display state before every run for a reason.

A shader-emission or shared-crate change has wide, subtle fallout. Run the whole suite before committing, not just the subtest you were working on.

Companion edits that belong in the same change

Each of these rots silently when it is left for later:

  • A new render-state, texture-stage-state or sampler-state consumer moves its slot to consumed in the matching classifier, and flips the matching caps bit. The classifier warnings are only useful while every warning is a real gap.
  • Shader emission has a source-derived fingerprint (windows/core/build.rs). Changes under dxso/ and to its shared MSL inputs invalidate MSL automatically; extend the source list when adding an emission dependency outside those paths. Bump the shader-cache schema for incompatible shader keys or pipeline-translation semantics, and the container version for binary-format changes. Programmable DXSO survives emitter changes; fixed-function entries rebuild on first use.
  • A new config key ships with its dispatch arm, its unit test, and its entry in the mtld3d.conf sample with the default and a short why.
  • A new built-in app profile ships with the rationale for every key it sets as the comment on its entry in windows/core/src/app_profile.rs and a test that resolves it from the version strings the shipped binary actually carries. The README links to that file rather than duplicating the profile list. A profile that pins no version field is not a profile, it is a name collision waiting to happen.
  • A new Clone or Copy derive updates scripts/derive_inventory.txt (scripts/audit.sh --update-derives).

Standing rules worth knowing before you write code

The full set is in docs/CONVENTIONS.md. These are the ones a newcomer trips:

  • No new MTLD3D_* environment variable. A runtime knob is a mtld3d.conf key; a diagnostic is a narrowed mtld3d::* log target consumed through RUST_LOG.
  • No fourth #[allow]. The tree carries exactly three, and complexity lints are never suppressed: introduce a parameter struct instead.
  • No silent failures. Every stub, fallback and catch-all arm logs once.
  • No pub(crate), no mod.rs, no type aliases, no glob imports, no raw Objective-C selectors.
  • Hash maps use FxHashMap; content hashing uses xxh3.
  • Pure logic belongs in mtld3d-core; windows/d3d9 is COM wiring. A COM wrapper carries a vtable pointer, a refcount and an opaque inner pointer, and every other field lives on the inner struct.
  • Every integer with symbolic meaning that crosses the boundary is a typed value in unix/shared, never a bare u32 and never a locally restated constant.
  • Comments state the invariant, not the history that produced it: no incident provenance, no upstream test-file citations outside unix/conformance/, no absolute paths from someone's machine. The audit greps for these.
  • No em dashes in prose, comments, commit messages or documentation.

Pull requests

main is protected, so everything lands through a pull request, maintainers included, and pull requests are squash-merged.

That makes the pull request, not the commit, the unit of review and the unit of history. One pull request is one clearly defined change, and the squashed commit is what has to stay bisectable on main. No drive-bys: an unrelated cleanup, a rename or a reformat picked up along the way goes in its own pull request. It keeps the review small and keeps main at one change per commit.

Commits inside the branch are discarded by the squash, so they need no polish. The description is what survives, and for anything non-trivial it carries:

  1. The user-visible symptom, quoting the exact log line or error where there is one.
  2. The root cause, the mechanism, and say so when it is conjecture.
  3. The fix, in its minimum conceptual steps.
  4. At least one considered alternative, including the dead ends.
  5. Verification: the commands you ran and what a reviewer should look for.

CI compiles on two machines and replays everywhere else. One job builds the stage (make stage: both PE arches, both unix .so builds, the e2e test binaries, the e2e and conformance runners for both host arches) and another runs formatting and audit first, then documentation, Clippy and unit tests as steps of one job. Production bundles are built by the release job only. Every run on main has its own concurrency group so pending runs survive later pushes and every commit keeps its CI result. PR updates cancel the superseded run. The test machines carry no toolchain: they install the stage (STAGE=<dir>) and run the end-to-end and conformance suites on three images: the newest macOS on arm64, the oldest macOS mtld3d supports on arm64, and the Intel image, whose device has no unified memory and none of the packed 16-bit formats, so it runs the Intel/AMD code paths for real. One more end-to-end leg, and one more conformance leg, run their suite at render.scale = 0.75, the evidence that the coordinates the tests assert on stay in the space D3D9 reports when the frame is rasterized smaller; a test that needs single-pixel resolution asks render_scale_is_identity() and pins its exact shape at the identity rather than failing that leg, and the conformance sites that cannot (a probe on a colour boundary) are classified under "The scaled leg" in unix/conformance/CONFORMANCE.md. Every image gates. The Intel image reads the conformance baseline's @mac2 entries, which only it can record: dispatch the workflow with record_intel_baseline and commit the @mac2 sections from the baseline-mac2-<arch> artifacts (unix/conformance/CONFORMANCE.md has the procedure). A conformance subtest that dies on one image now and then is caught by dispatching with conformance_repeat=<n>, which runs it that many times on every image and uploads each run's raw output, ending in how the process ended, with the layer's debug log beside it. Run make conformance locally when your change touches the render or shader-emission path. The manual probe-metal job is how an image is checked before it is added, and it runs under the forced Intel answers, because its device filters 32-bit floats where a real Intel/AMD Mac's driver does not. On an Apple-family machine the forced answers (make test INTEL=1, make conformance-intel) are the way to run the Intel paths without the hardware.

The end-to-end legs run one test at a time in CI on purpose (JOBS=1, against a local default of four), because parallel device creation aborts on a runner. A flake there is not fixed by re-enabling parallelism.

What sends a pull request back

  • make check or make test is red, or the run was judged by the summary.
  • A new lint suppression, or a new environment variable.
  • A conformance regression with no re-recorded baseline and no rationale.
  • An end-to-end assertion deleted without saying which behaviour it contradicted.
  • A behaviour change that diverges from D3D9 without being written down as a decision.
  • A missing companion edit: classifier arm, caps bit, cache-schema bump, config sample entry, derive inventory.
  • Incident provenance or a private path in a comment.
  • Two unrelated changes in one branch.

Issues and labels

The GitHub tracker is the backlog. A finding worth keeping, whether it comes out of a review, a session, or a game report, becomes an issue the moment it is not being fixed on the spot: one issue per finding, verified against the source before filing.

Every open issue carries one type label, one priority label, and, where the work can be estimated, one effort label:

  • Type: bug is wrong behaviour in an implemented path; enhancement is a new capability or an unimplemented part of the D3D9 surface; performance is speed or memory with no behaviour change; game-compat is a specific game failing or misbehaving; infra is build, CI, test harness, or tooling.
  • Priority: P1 is next in line, a user-visible breakage or a live correctness hazard; P2 is real and expected to get done; P3 is speculative, nice-to-have, or waiting on its trigger.
  • Effort: effort/S is hours; effort/M is a normal single-PR change; effort/L is multi-day or needs a design first.

Two modifiers exist. blocked marks an issue waiting on an external trigger, a machine we do not have, or an upstream change, with the body naming exactly what it waits for; a blocked issue keeps its priority, the label only says why it is not moving. needs-repro marks an external report that has not been reproduced or root-caused locally yet.

File the issue with its labels attached. When a claim in an issue body stops being true, edit the body rather than correcting it in a comment trail.

Cutting a release

A release is a v* tag, and pushing one is the whole trigger: CI drafts the release, attaches the two archives make bundle produces, and leaves the body empty for the notes, which are written by hand.

The version lives in the [workspace.package] table of both Cargo.tomls and in the local-crate entries of both Cargo.locks, so the bump is its own commit touching four files. Refresh each lock with cargo metadata --format-version 1 --manifest-path <workspace>/Cargo.toml > /dev/null, which rewrites the version lines without the dependency churn cargo update would bring, and check the bump before tagging with make version-check TAG=vX.Y.Z.

Land that commit, wait for its run on main to go green, then push the tag. The release job refuses a tag whose version disagrees with either workspace, and refuses one whose commit has no green run on main; a tag pushed while that run is still going is waited on rather than rejected. It builds the bundle itself, after the tag exists, because every binary stamps git describe and one built before the tag names the previous release.

What is left is the notes and the publish. Group the points into sections rather than one flat list, give the two or three headline items a section each, and end every point with the pull requests that did it. Then gh release edit vX.Y.Z --draft=false --latest.