Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | 15x 15x 15x 15x 15x 15x 525502x 3617x 1170x 158x 158x 100x 206x 57585167x 7630x 329x 329x 329x 329x 4676x 4676x 4666x 329x 170x 163x 163x 4x 4x 3x 3x 52x 49x 44x 86x 44x 82x 37x 37x 37x 37x 37x 37x 37x 155x 15x 164x 164x 164x 164x 164x 164x 164x 164x 169x 169x 169x 169x 153x 156x 391x 391x 391x 5x 5x 5x 391x 391x 391x 391x 391x 391x 391x 391x 11x 11x 391x 391x 63x 63x 63x 63x 63x 74x 74x 74x 75x 75x 75x 238x 65x 65x 3x 62x 62x 62x 62x 62x 62x 62x 58x 58x 58x 58x 11x 11x 11x 58x 45x 45x 45x 7x 55x 10x 10x 10x 10x 10x 60x 10x 10x | // Deterministic lockstep for a shared session: every machine starts from the
// host's snapshot and applies the same inputs at the same emulated cycle, so
// they stay identical without sending any machine state. One machine is the
// sequencer, the host unless someone has taken control; it runs as normal, and
// what it executes, and the inputs it applied between executes, go out as commits
// that every other machine replays exactly. See docs/shared-sessions-design.md.
import { RomBankBytes } from "./6502.js";
import { Disc, DiscConfig } from "./disc.js";
import { KeyMatrixSize } from "./keymap.js";
const HashIntervalSeconds = 1;
// A guest more than MaxGuestLagSeconds behind the host catches up by running up to MaxCatchUpSeconds per execute
// instead of its usual slice, so a long way behind is made up over several frames rather than in one stall.
export const MaxGuestLagSeconds = 0.04;
export const MaxCatchUpSeconds = 0.1;
const FnvOffset = 0x811c9dc5;
const FnvPrime = 0x01000193;
const MsPerMinute = 60 * 1000;
/**
* The emulated cycle the machine has reached, counted from power on. Between
* executes it is an instruction boundary, which is what makes it a place another
* machine running the same code can stop at exactly.
*/
export function cycleCount(cpu) {
return cpu.cycleSeconds * cpu.model.cyclesPerSecond + cpu.currentCycles;
}
/** Changes whenever the processor does: as its cycle count moves, and at a reset or state load, which need not. */
export function cpuMoment(cpu) {
return `${cpu.resetsAndLoads}:${cycleCount(cpu)}`;
}
/** Changes whenever the machine does: as its processor does, and as a key goes down or up, which takes no cycles. */
export function machineMoment(cpu) {
return `${cpuMoment(cpu)}:${cpu.sysvia.keyChanges}`;
}
// The CPU runs whole instructions until it reaches its target, so asking for a
// cycle that some machine stopped at lands on it exactly. The target is set rather
// than added to, since a loop asking for fractions of a cycle leaves it fractional,
// and adding to that rounds.
function runTo(cpu, cycle) {
cpu.targetCycles = cycle - cpu.cycleSeconds * cpu.model.cyclesPerSecond;
return cpu.execute(0);
}
const cyclesToMs = (cpu, cycles) => (cycles * 1000) / cpu.model.cyclesPerSecond;
/**
* Puts the machine's CMOS in the session, its clock reading `baseMs` plus the machine's
* emulated cycles: the same time on every machine in the session.
*/
function joinSessionCmos(cpu, baseMs) {
cpu.sysvia.cmos.joinSession(() => baseMs + cyclesToMs(cpu, cycleCount(cpu)));
}
function fnv1a(hash, bytes) {
for (let i = 0; i < bytes.length; ++i) hash = Math.imul(hash ^ bytes[i], FnvPrime);
return hash;
}
/** A cheap fingerprint of the machine for desync detection: registers, RAM, sideways RAM and the keyboard. */
export function stateHash(cpu) {
const registers = Uint8Array.of(cpu.a, cpu.x, cpu.y, cpu.s, cpu.pc & 0xff, cpu.pc >>> 8, cpu.p.asByte());
let hash = fnv1a(FnvOffset, registers);
hash = fnv1a(hash, cpu.ramRomOs.subarray(0, cpu.romOffset));
cpu.model.swram.forEach((isRam, bank) => {
const start = cpu.romOffset + bank * RomBankBytes;
if (isRam) hash = fnv1a(hash, cpu.ramRomOs.subarray(start, start + RomBankBytes));
});
for (const column of cpu.sysvia.keys) hash = fnv1a(hash, column);
return (hash >>> 0).toString(16);
}
/**
* Applies an input between executes. A session sends only keys and BREAK; the rest are what a replay of this
* machine's own past (src/replay.js) puts back, as its input log recorded them.
*/
export function applyInput(cpu, input) {
switch (input.kind) {
case "key":
cpu.sysvia.setMapped(input.mapping, input.down ? 1 : 0);
break;
case "break":
cpu.setReset(input.down);
break;
case "keyboard":
cpu.keyboardInterface.restoreKeyboard(input.state);
break;
case "gamepadFire":
cpu.sysvia.setGamepadFire(input.button1, input.button2);
break;
case "joystickButton":
cpu.sysvia.setJoystickButton(input.button, input.pressed);
break;
case "reset":
cpu.reset(false);
break;
case "write":
cpu.writemem(input.address, input.value);
break;
default:
throw new Error(`Unknown input "${input.kind}"`);
}
}
/** Checks an input from a peer has the shape applyInput expects, so a bad one cannot throw in a machine's loop. */
export function isValidInput(input) {
if (input?.kind === "break") return typeof input.down === "boolean";
if (input?.kind !== "key" || typeof input.down !== "boolean" || !Array.isArray(input.mapping)) return false;
const [col, row, shiftOverride] = input.mapping;
const inMatrix = (n) => Number.isInteger(n) && n >= 0 && n < KeyMatrixSize;
return (
input.mapping.length <= 3 &&
inMatrix(col) &&
inMatrix(row) &&
(shiftOverride === undefined || typeof shiftOverride === "boolean")
);
}
export function isValidCommit(commit) {
return (
Number.isInteger(commit?.at) &&
Number.isInteger(commit.upTo) &&
commit.upTo >= commit.at &&
Array.isArray(commit.inputs) &&
commit.inputs.every(isValidInput) &&
(commit.hash === undefined || typeof commit.hash === "string")
);
}
/**
* Makes `cpu` the machine a session snapshot describes. A drive restores tracks only into
* a disc it already holds, which may be this person's own and write back to where it came
* from, so each drive first gets a fresh disc of its own, or none if the host's is empty.
* The CMOS joins first, as the restore reads its clock, in the session's time, to put port A back.
* The reset line, which snapshots leave out like the held keys, travels beside it too.
*/
export function restoreSessionSnapshot(cpu, snapshot) {
const { state, keyboard, resetting, at, rtcBaseMs } = snapshot;
cpu.fdc.drives.forEach((drive, index) => {
drive.setDisc(state.fdc?.drives?.[index]?.disc ? new Disc(true, new DiscConfig(), "") : undefined);
});
joinSessionCmos(cpu, rtcBaseMs);
// The keys first, so the system VIA's restore scans the host's keyboard, not this person's.
cpu.sysvia.restoreKeyboard(keyboard);
cpu.restoreState(state);
cpu.setReset(resetting);
Iif (cycleCount(cpu) !== at) throw new Error(`restored to cycle ${cycleCount(cpu)}, not ${at}`);
}
/**
* What a joiner needs to become `cpu`, which ordinary snapshots leave part of out: the ROMs, the keys held down,
* the reset line, the cycle it is at and the wall time the session's clock counts from.
*/
export function sessionSnapshot(cpu, rtcBaseMs) {
return {
state: cpu.snapshotState({ includeRoms: true }),
keyboard: cpu.sysvia.keyboardState(),
resetting: !cpu.resetLine,
at: cycleCount(cpu),
rtcBaseMs,
};
}
const commitSpan = ({ at, upTo }) => ({ at, upTo });
/**
* The host's side. Wraps the machine's execute: inputs queued since the last
* execute are applied at the cycle the machine has reached, then the machine
* runs, and the commit describing both goes to `send`. If the machine is found
* somewhere other than where the last execute left it (a hard reset zeroes the
* cycle count, a loaded state moves it anywhere), `onJump` is told before the
* next commit, since no guest can follow that by replaying.
*
* The host also keeps the session's clock: its RTC reads its wall time at `rtcBaseMs`
* plus emulated cycles, starting again from the wall time after a jump.
*/
export class LockstepHost {
constructor(cpu, send, onJump, { rtcBaseMs } = {}) {
this.cpu = cpu;
this.send = send;
this.onJump = onJump;
this.pending = [];
this.hashInterval = HashIntervalSeconds * cpu.model.cyclesPerSecond;
this.reachedAt = cycleCount(cpu);
this.nextHashAt = this.reachedAt + this.hashInterval;
this.startClock(rtcBaseMs);
}
// The wall time here, as a clock read in UTC shows it, at cycle zero.
startClock(rtcBaseMs) {
const { cpu } = this;
const wallMs = Date.now() - new Date().getTimezoneOffset() * MsPerMinute;
this.rtcBaseMs = rtcBaseMs ?? wallMs - cyclesToMs(cpu, cycleCount(cpu));
joinSessionCmos(cpu, this.rtcBaseMs);
}
snapshot() {
return sessionSnapshot(this.cpu, this.rtcBaseMs);
}
input(input) {
this.pending.push(input);
}
execute(cycles) {
const { cpu } = this;
const at = cycleCount(cpu);
if (at !== this.reachedAt) {
this.nextHashAt = at + this.hashInterval;
this.startClock();
this.onJump();
}
const inputs = this.pending.splice(0);
for (const input of inputs) applyInput(cpu, input);
const running = cpu.execute(cycles);
// execute() adds each request to a running target, so a machine stopped early (a breakpoint) would
// otherwise run what it had left as well when it resumes, in a commit longer than was asked for.
if (!running) cpu.targetCycles = cpu.currentCycles;
const upTo = cycleCount(cpu);
this.reachedAt = upTo;
const commit = { type: "commit", at, inputs, upTo };
if (upTo >= this.nextHashAt) {
commit.hash = stateHash(cpu);
this.nextHashAt = upTo + this.hashInterval;
}
this.send(commit);
return running;
}
}
/**
* A guest's side. Commits from the host queue up; execute replays them, running
* no further than the host has, applying each input at the cycle it was applied
* at and checking the host's hashes where they were taken. `onDesync` is called
* once when this machine and the host's part, and not again until `resync`, with
* the reason, the commits it had not yet replayed (which it drops), whether it was
* this machine that moved by itself, and the evidence: the `cycle` it was at, and
* whichever of the commit's `at` and `upTo`, the cycle it moved `from`, and the
* `expectedHash` the commit carried and this machine's `hash` apply.
*/
export class LockstepGuest {
constructor(cpu, onDesync) {
this.cpu = cpu;
this.onDesync = onDesync;
this.maxLag = MaxGuestLagSeconds * cpu.model.cyclesPerSecond;
this.maxCatchUp = MaxCatchUpSeconds * cpu.model.cyclesPerSecond;
this.resync();
}
/** Starts again from the machine as it is now, having been given the host's. */
resync() {
this.commits = [];
this.upTo = this.reachedAt = cycleCount(this.cpu);
this.desynced = false;
}
/** Takes the host's next commit, which must start where the last one ended. */
receive(commit) {
Iif (commit.at !== this.upTo) {
this.desync(`a commit starts at ${commit.at}, not ${this.upTo}`, commitSpan(commit));
return;
}
this.commits.push(commit);
this.upTo = commit.upTo;
}
/** How many cycles the host is ahead of this machine. */
behind() {
return this.upTo - cycleCount(this.cpu);
}
/**
* Runs about `cycles`, more if far behind the host, never past the host. Returns
* false if the machine stopped (as `cpu.execute` does).
*/
execute(cycles) {
const { cpu } = this;
if (cycleCount(cpu) !== this.reachedAt) {
return this.desync(`its machine moved from ${this.reachedAt} by itself`, { from: this.reachedAt }, true);
}
const running = this.replay(cycles);
this.reachedAt = cycleCount(cpu);
return running;
}
replay(cycles) {
const { cpu } = this;
const catchUp = Math.min(this.behind() - this.maxLag, this.maxCatchUp);
const limit = Math.min(this.upTo, cycleCount(cpu) + Math.max(cycles, catchUp));
while (this.commits.length > 0) {
const commit = this.commits[0];
Iif (commit.at > limit) break;
Iif (!runTo(cpu, commit.at)) return false;
if (commit.inputs.length > 0) {
Iif (cycleCount(cpu) !== commit.at) {
return this.desync(`reached ${cycleCount(cpu)} for ${commit.at}`, commitSpan(commit));
}
for (const input of commit.inputs) applyInput(cpu, input);
commit.inputs = [];
}
if (commit.upTo > limit) break;
Iif (!runTo(cpu, commit.upTo)) return false;
this.commits.shift();
if (commit.hash !== undefined && stateHash(cpu) !== commit.hash) {
return this.desync(`state differs at cycle ${commit.upTo}`, {
...commitSpan(commit),
expectedHash: commit.hash,
hash: stateHash(cpu),
});
}
}
return runTo(cpu, limit);
}
desync(reason, { at, upTo, from, expectedHash, hash }, moved = false) {
const dropped = this.commits;
this.commits = [];
Eif (!this.desynced) {
this.desynced = true;
const evidence = { cycle: cycleCount(this.cpu), at, upTo, from, expectedHash, hash };
const known = Object.entries(evidence).filter(([, value]) => value !== undefined);
this.onDesync(reason, dropped, moved, Object.fromEntries(known));
}
return true;
}
}
|