diff --git a/README.md b/README.md index b69c711..9a631a9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ the same model that moves the parts. Left alone, each page plays a guided tour; | | The machine | What drives it | Checked by | |---|---|---|---| +| [**Rope**](machines/rope) | The Apollo Guidance Computer Block II and its DSKY, as flown in Apollo 11's lunar module: a word read from the core rope (a wire through a core is a 1, around it a 0), an erasable core read and rewritten, one kind of NOR gate, the Executive running out of core sets as the rendezvous radar steals cycles, the 1202. A model after public sources (MIT Instrumentation Lab, NASA); not affiliated with NASA or MIT. | The mission time, counted in memory cycles since PDI. The Executive's log (jobs, priorities, radar steals, the crew's keys) is planned once. | `machines/rope/check.mjs` | | [**Quantum**](machines/quantum) | A photonic quantum computer: a quantum dot emits one photon per laser pulse, fibre delay lines bring them onto a 24-mode chip together, 276 Mach-Zehnder interferometers mix them, superconducting detectors click. A Hong-Ou-Mandel test on one coupler, the Perceval program behind the heaters. Modelled on the public figures of Quandela's Belenos; not affiliated with Quandela. | The physical time τ of the shot, in nanoseconds. The output distribution is computed from the permanents of the chip's unitary. | `machines/quantum/check.mjs` | | [**Spindl**](machines/spindl) | A 3.5-inch 7,200 rpm desktop hard drive (2 platters, 4 heads): spin-up, the heads loading off the ramp, a seek drawn live, the rotational wait, the 3 nm flying height, the emergency park. A model of a typical drive, not a specific product. | The drive time τ. A request queue (LBA → zone, cylinder, head, sector) is planned once. | `machines/spindl/check.mjs` | diff --git a/machines/rope/FILM.md b/machines/rope/FILM.md new file mode 100644 index 0000000..3dc9e64 --- /dev/null +++ b/machines/rope/FILM.md @@ -0,0 +1,23 @@ +# The film + +The tour (40 s) is recorded frame by frame from `machines/rope/agc-film`, the same code as the page, with the capture +tools of the teardown skill ([whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion)). They need the +skill installed (`SKILL_DIR`), headless Chrome with a real GPU, `ffmpeg` and Python 3. + +```sh +cd machines/rope +node agc-shared/make-timeline.mjs # agc-film/timeline.json (beats) and sfx.json (keys, alarms, calls) +node "$SKILL_DIR/sims/build.mjs" ./agc-film +echo '{"lang":"en"}' > teardown-out/en.json # or fr / es +node "$SKILL_DIR/sims/capture.mjs" agc-film --timeline agc-film/timeline.json --config teardown-out/en.json --size 1600x900 --dpr 1.2 --stills beats --cols 4 --out teardown-out/beats.png +node "$SKILL_DIR/sims/capture.mjs" agc-film --config teardown-out/en.json --size 1600x900 --dpr 1.2 --out teardown-out/silent.mp4 +python3 "$SKILL_DIR/run_sfx.py" agc-shared/score.py # teardown-out/rope-score.wav, mastered to −14 LUFS +ffmpeg -y -i teardown-out/silent.mp4 -i teardown-out/rope-score.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart teardown-out/rope-en.mp4 +``` + +The share card is the film's first frame, scaled: `ffmpeg -y -i teardown-out/rope-en.mp4 -frames:v 1 -vf "crop=1920:1008:0:36,scale=1200:630" -map_metadata -1 og.png`. + +On macOS, Playwright's headless shell may get no WebGL context; point the capture at Chrome: +`CHROMIUM="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"`. + +`{"lang":"en","clean":true}` records the machine without the console. diff --git a/machines/rope/REFERENCES.md b/machines/rope/REFERENCES.md new file mode 100644 index 0000000..f3f1443 --- /dev/null +++ b/machines/rope/REFERENCES.md @@ -0,0 +1,98 @@ +# Rope: references + +Rope is a model of the Apollo Guidance Computer Block II and its DSKY, as flown in the lunar module of Apollo 11 +(Luminary 099), built from public sources: the MIT Instrumentation Lab's documents and code as preserved by the +Virtual AGC project, NASA's mission records, and the restoration work of Ken Shirriff and Marc Verdiell. It is not +affiliated with NASA, MIT or Draper. Where a figure is not public or could not be found, the model uses a plausible +value and marks it `[E]` below and in [`agc-shared/machine.js`](agc-shared/machine.js), where every number lives. + +## Sources + +- **[W]** [Apollo Guidance Computer](https://en.wikipedia.org/wiki/Apollo_Guidance_Computer), Wikipedia, and the + documents it cites: 16-bit word (15 data bits + odd parity), ones' complement, 36,864 words of core rope and 2,048 + of erasable core, 2.048 MHz clock, 11.72 µs memory cycle, ~2,800 flat-packs of dual 3-input NOR gates, 32 kg + (70 lb), 55 W, 61 × 32 × 17 cm (24 × 12.5 × 6.5 in), green electroluminescent seven-segment digits. +- **[S]** Ken Shirriff, [Software woven into wire: core rope and the Apollo Guidance Computer](https://www.righto.com/2019/07/software-woven-into-wire-core-rope-and.html) + (2019): 6 rope modules of 6,144 words, 512 cores each (two layers of 256), 12 words per core on 192 sense lines + (12 strands of 16), inhibit lines that select one core, set and reset pulses, a wire through the core reads 1 and + a wire around it reads 0; the photographs of the opened AGC (trays A and B), of tray B's modules B7-B17, of the + inside of a rope module, and the simplified rope diagram the geometry was built against. +- **[L]** Luminary 099 (Apollo 11 LM), as transcribed by the [Virtual AGC project](https://www.ibiblio.org/apollo/) + ([listings](https://www.ibiblio.org/apollo/listings/Luminary099/), [chrislgarry/Apollo-11](https://github.com/chrislgarry/Apollo-11)): + - `EXECUTIVE.agc`: NOVAC2, NOVAC3, NEXTCORE, and `TC BAILOUT1` / `OCT 1202`; the assembled words and their + addresses shown in the rope (`01,2625` to `01,2703`) are copied from the listing. + - `ERASABLE_ASSIGNMENTS.agc`: "EIGHT SETS OF 12 REGISTERS EACH" (the core sets), five VAC areas. + - `PINBALL_NOUN_TABLES.agc`: Noun 63 (absolute velocity, altitude rate, computed altitude), Noun 64 (time left + for redesignation and LPD angle, altitude rate, altitude), Noun 68 (slant range to the landing site, time to go in + the braking phase, LR altitude minus computed altitude), Noun 60. + - `LUNAR_LANDING_GUIDANCE_EQUATIONS.agc`, `THE_LUNAR_LANDING.agc`: V06N63 in P63, V06N64 in P64, DELTAH for V16N68. + - `ALARM_AND_ABORT.agc`: BAILOUT1's `INDEX Q` / `CAF 0`, which fetches the alarm code from the rope. +- **[E1]** Don Eyles, [Tales from the Lunar Module Guidance Computer](https://www.doneyles.com/LM/Tales.html) + (AAS, 2004): the duty-cycle margins (over 15 % in braking, about 13 % once the landing radar's data come in, 10 % + or less with Verb 16 Noun 68 up, below 10 % in P64), the radar's TLOSS (2 angles × up to 6,400 pulses per second, + 13 % assumed, 13.36 % measured after the flight), SERVICER every 2 s, 8 core sets and 5 VAC areas, the restart + that keeps only the newest SERVICER and drops the uncompleted ones and the Noun 68 monitor, high gate at + 102:41:32 and 7,400 ft, P66 at 102:43:08. +- **[J]** [Apollo 11 Lunar Surface Journal](https://apollojournals.org/alsj/a11/a11.landing.html), the landing: + the crew's altitude and speed call-outs from 5,000 ft to contact, "Delta-H is minus 2,900", the ALT light out at + 102:38:11, the altitude and velocity lights near the surface, "Contact light" at 102:45:40. +- **[R]** Apollo 11 Mission Report and NASA timeline: PDI at 102:33:05, alarms at 102:38:22 (1202), 102:39:02 + (1202), 102:42:18 (1201), 102:42:43 (1202), 102:42:58 (1202). +- **[G]** [Apollo descent guidance](https://www.nasa.gov/wp-content/uploads/static/history/alsj/ApolloDescentGuidnce.pdf) (report on the ALSJ): + the braking phase ends about 7,800 m in slant range from the landing site. +- **[D]** The LM DSKY: the photographs at the Computer History Museum and NASM (Wikimedia Commons, below), the + Luminary lamp list; 8 × 8 × 7 in. +- **[H]** The AGC's instruction timing (Virtual AGC, "Assembly language manual"): 12 timing pulses per MCT, AD in + 2 MCT, TC and TCF in 1, DXCH in 3. +- **[P]** Pinball (the DSKY program in Luminary): a Verb 16 monitor redisplays once a second. +- **[Q]** [Quindar tones](https://www.nasa.gov/wp-content/uploads/static/history/alsj/quindar.html), ALSJ: + 2,525 Hz to key the ground transmitter, 2,475 Hz to release it, 250 ms each. +- **[A]** Apollo Operations Handbook, Block II spacecraft, vol. 1, section 2.10, caution and warning + ([ibiblio](https://www.ibiblio.org/apollo/ApolloProjectOnline/Documents/SMA2A-03-BLOCK%20II%20Volume%201%2019691015/aoh-v1-2-10-c+w.pdf)): + the master alarm tone is a square wave alternately 750 and 2,000 Hz. That is the command module's handbook; the + score assumes the LM's tone was the same. + +## Estimates and simplifications **[E]** + +| What | In the model | Why | +|---|---|---| +| Job mix of the Executive | SERVICER every 2 s (needs a VAC area), a Verb 16 monitor job every second at lower priority, an LPD display job every 2 s in P64 from 12 s after high gate, one keyboard job per key | the real Luminary job list is longer; this is the smallest mix that shows the mechanism Eyles describes | +| Priorities | relative only (keyboard > SERVICER > monitor, LPD) | the numbers are not shown on screen; the priority word in the erasable view uses PRIO20 / PRIO10 and an estimated low-9-bit pointer (`0154`) | +| Radar data load | +1.5 % (ramping over 24 s once the landing radar locks at 102:38:11) | Eyles gives "perhaps 13 %" margin; 1.5 % instead of 2 % keeps the braking phase from drifting into overload between the second alarm and P64, as it did not in flight | +| P64 duty | 83.5 % rising to 90 % at P66 | Eyles: margin below 10 % in P64; the ramp places the model's P64 alarms near the flown ones | +| Crew keystrokes | V16 N68 entered at PDI + 305 s, V05 N09 after the first alarm, V16 N68 again at PDI + 350 s, RSETs | the keystroke times are not in the transcript; they are set so the model's first two 1202s land at 317.4 s and 356.4 s after PDI (flown: 317 and 357) | +| Descent profile before high gate | a smooth braking profile through 50,000 ft at PDI, 33,500 ft at the first alarm, 7,400 ft at high gate; horizontal speed 5,560 ft/s at PDI | nobody called numbers in the braking phase; the call-outs after high gate are the crew's [J] | +| Delta-H | −2,900 ft when the radar locks [J], then decaying with a 38 s time constant | the shape of its incorporation is not published | +| Rope strand mapping | word w of a module sits on core w mod 512, strand ⌊w / 512⌋ | the real mapping (address bits to core and strand) is more involved; this one puts consecutive addresses on neighbouring cores, so the 9 cores shown hold the real words `01,2623` to `01,2633` | +| Phases inside the memory cycle | inhibit, set, reset, strobe drawn over the 12 pulses; the 16 bits drawn one after the other | the sense amplifiers read all 16 at once; the drawing staggers them so the eye can follow | +| Erasable plane | 34 × 11 cores of one bit plane (bit 14), coincident-current X / Y lines, one sense line | the AGC's erasable module packs 32,768 cores; the plane is a window onto one of them | +| Inside tray B | the rope bay at one end (3 across, 2 deep), B7-B17 standing across the middle, the X-ribbed plate at the other end | after the photographs of the opened trays; the exact positions are approximate | +| Model's P64 alarms | three 1201s at 577, 586 and 598 s | flown: a 1201 at 553 s and two 1202s at 578 and 593 s; the model's VAC areas run out before its core sets | +| The instruction-per-second figure | 85,333 MCT/s; additions take 2 MCT, so 42,667 additions/s | "about 85,000 instructions per second" holds only for one-MCT instructions (TC, TCF) | + +## The model, by hand + +- 2.048 MHz ÷ 2 = 1.024 MHz; 12 pulses = 11.72 µs per memory cycle; 85,333 cycles a second. +- 6 modules × 512 cores × 12 words = 36,864 words of rope = 36 banks of 1,024; × 16 bits = 589,824 bits. +- 2,048 erasable words × 16 bits = 32,768 cores. +- The radar's two angles at 6,400 pulses per second each steal 12,800 MCT a second: 15.0 % at most; 13 % in the model. +- At the first alarm: 85 % braking + 1.5 % radar data + 3 % Noun 68 + 13 % stolen = 102.5 % of the machine. +- CAF NO.CORES at `01,2627` is `32634` octal = `011 010 110 011 100`, eight ones; the parity bit is 1. + +`node machines/rope/check.mjs` recomputes each of these from the code, runs the Executive's log and compares its +alarms with Apollo 11's. + +## Study material + +The photographs and documents the geometry was built against belong to their authors and are not in this +repository (they sit in `machines/rope/ref/`, which git ignores): + +- Ken Shirriff's photographs in [S]: the AGC closed, the two trays opened, tray B, a rope module opened, the rope + diagram. +- [Apollo DSKY at the Computer History Museum](https://commons.wikimedia.org/wiki/File:Apollo_DSKY_CHM.agr.jpg) + (Arnold Reinhold, CC BY-SA), [Apollo Guidance Computer at NASM](https://commons.wikimedia.org/wiki/File:Apollo_Guidance_Computer_(NASM).JPG), + [AGC logic module](https://commons.wikimedia.org/wiki/File:AGClogicmodule.agr.jpg), + [core rope memory](https://commons.wikimedia.org/wiki/File:Apollo_guidiance_computer_ferrit_core_memory.jpg). +- The Luminary 099 source files listed in [L]. + +The console's lettering is Barlow Condensed (SIL Open Font License, [@fontsource/barlow-condensed](https://fontsource.org/fonts/barlow-condensed)). diff --git a/machines/rope/agc-film/main.js b/machines/rope/agc-film/main.js new file mode 100644 index 0000000..3655774 --- /dev/null +++ b/machines/rope/agc-film/main.js @@ -0,0 +1,4 @@ +// Rope, the film. Shared code in ../agc-shared. +import {run} from '../agc-shared/film.js'; + +run(); diff --git a/machines/rope/agc-film/sfx.json b/machines/rope/agc-film/sfx.json new file mode 100644 index 0000000..3c32ca1 --- /dev/null +++ b/machines/rope/agc-film/sfx.json @@ -0,0 +1 @@ +{"keys":[29.224,29.513,29.802,30.162,30.451,30.74,31.173,32.111],"alarms":[{"t":27.6,"code":1202},{"t":33.503,"code":1202},{"t":35.132,"code":1201},{"t":35.189,"code":1201},{"t":35.266,"code":1201}],"calls":[[28.5,29.9],[31.2,32.2],[37.6,38.8]],"quindar":{"intro":2525,"outro":2475,"ms":250}} \ No newline at end of file diff --git a/machines/rope/agc-film/timeline.json b/machines/rope/agc-film/timeline.json new file mode 100644 index 0000000..63ff34f --- /dev/null +++ b/machines/rope/agc-film/timeline.json @@ -0,0 +1 @@ +{"duration":40,"beats":{"views":[3,6.8,14.6,20.2,24,32.4],"ropeRead":10.2,"erasRead":16,"alarm":27.6,"ff":32.4,"contact":37.4}} \ No newline at end of file diff --git a/machines/rope/agc-live/main.js b/machines/rope/agc-live/main.js new file mode 100644 index 0000000..6ec7377 --- /dev/null +++ b/machines/rope/agc-live/main.js @@ -0,0 +1,5 @@ +import {runLive} from '../agc-shared/live.js'; + +// Rope as an interactive page: seven subsystems, sections, views, the DSKY's keys, the radar's cycle steals, the +// mission speed, orbit and zoom, and an auto tour when left alone. +runLive(); diff --git a/machines/rope/agc-shared/agc.js b/machines/rope/agc-shared/agc.js new file mode 100644 index 0000000..d90ff31 --- /dev/null +++ b/machines/rope/agc-shared/agc.js @@ -0,0 +1,873 @@ +// Rope's geometry, built from code, in metres: the AGC Block II (two magnesium trays bolted face to face, 61 x 32 x +// 17 cm [W]) and the LM DSKY (20 x 20 x 18 cm [D]), after the photographs in REFERENCES.md. Tray A (bottom) holds the +// 24 logic and 7 interface modules standing on edge [S]; tray B holds the memory electronics B7-B17, the erasable +// module B12 (black), the X-ribbed plate, and the bay of the 6 rope modules B1-B6 that slide in from the end [S]. +// The layout inside tray B is simplified [E]. update(fx) poses everything from the frame's state. +import * as THREE from 'three'; +import {AGC, LAMPS, ROPE_WORDS, bits16, ropePlace, wordAt} from './machine.js'; +import {drawDigits} from './seg7.js'; + +const V3 = THREE.Vector3; +export const ACCENT = '#35c26a'; // the DSKY's electroluminescent green +export const ACCENT_INK = '#1f8a4c'; // the same on paper +export const AMBER = '#f0a828'; +const COL = { + mag: '#a39976', // anodised magnesium, dull gold + magDark: '#7f7a62', + magGreen: '#8b8f78', // grey-green + strip: '#d2cbb7', // potted modules, cream grey + stripEdge: '#b9b19b', + black: '#24221f', + ferrite: '#1c1a18', + copper: '#b5713f', + gold: '#c09a4a', + ink: '#2b2925', + dsky: '#8f9194', + key: '#2a2b2d', + pcb: '#5f6b55', +}; +export const L = { + x0: -0.17, // the AGC's centre + len: 0.61, + wid: 0.32, + trayH: 0.085, + dsky: new V3(0.5, 0, 0.04), + dskyYaw: 0.3, + dskyS: 0.203, + dskyD: 0.178, + openZ: -0.37, // tray B beside tray A when apart + liftY: 0.24, +}; +const M0 = new THREE.Matrix4(); +const smooth = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * (3 - 2 * u); +}; +const mix = (a, b, k) => a + (b - a) * k; + +function std(color, o = {}) { + return new THREE.MeshStandardMaterial({color, roughness: 0.62, metalness: 0.15, envMapIntensity: 0.5, ...o}); +} +function box(w, h, d, mat, x = 0, y = 0, z = 0) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); + m.castShadow = true; + m.receiveShadow = true; + return m; +} +const font = (w, px) => `${w} ${px}px "Barlow Condensed", "Arial Narrow", sans-serif`; +function labelTex(text, {w = 512, h = 64, px = 40, color = '#2b2925', bg = null, weight = 600, align = 'center', spacing = 3} = {}) { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const g = c.getContext('2d'); + if (bg) { + g.fillStyle = bg; + g.fillRect(0, 0, w, h); + } + g.fillStyle = color; + g.font = font(weight, px); + g.textBaseline = 'middle'; + g.textAlign = align; + if ('letterSpacing' in g) g.letterSpacing = `${spacing}px`; + g.fillText(text, align === 'center' ? w / 2 : 12, h / 2 + 2); + const t = new THREE.CanvasTexture(c); + t.colorSpace = THREE.SRGBColorSpace; + t.anisotropy = 8; + return t; +} + +// ------------------------------------------------------------ a magnesium tray: plate, walls, pockets, flange, bolts +function tray({len, wid, h, mat, dark, holes}) { + const g = new THREE.Group(); + const t = 0.006; + const shell = []; + const add = (m) => { + g.add(m); + shell.push(m); + return m; + }; + add(box(len, t, wid, mat, 0, t / 2, 0)); + add(box(len, h, t, mat, 0, h / 2, wid / 2 - t / 2)); + add(box(len, h, t, mat, 0, h / 2, -wid / 2 + t / 2)); + add(box(t, h, wid - 2 * t, mat, len / 2 - t / 2, h / 2, 0)); + add(box(t, h, wid - 2 * t, mat, -len / 2 + t / 2, h / 2, 0)); + // the flange round the open side + const fl = 0.012; + add(box(len, 0.004, fl, mat, 0, h - 0.002, wid / 2 - fl / 2)); + add(box(len, 0.004, fl, mat, 0, h - 0.002, -wid / 2 + fl / 2)); + add(box(fl, 0.004, wid, mat, len / 2 - fl / 2, h - 0.002, 0)); + add(box(fl, 0.004, wid, mat, -len / 2 + fl / 2, h - 0.002, 0)); + // pockets milled into the long walls (the photos' rectangular recesses), and a rib between them + const pk = std(dark); + pk.polygonOffset = true; + pk.polygonOffsetFactor = -2; + const n = 4; + for (let i = 0; i < n; i++) { + const pw = len / n - 0.018; + const px = -len / 2 + (i + 0.5) * (len / n); + for (const s of [1, -1]) { + const p = new THREE.Mesh(new THREE.PlaneGeometry(pw, h * 0.62), pk); + p.position.set(px, h * 0.45, s * (wid / 2 + 0.0002)); + if (s < 0) p.rotation.y = Math.PI; + p.receiveShadow = true; + g.add(p); + shell.push(p); + } + } + // bolt holes on the flange + if (holes) { + const hg = new THREE.CylinderGeometry(0.0022, 0.0022, 0.0006, 10); + const hm = new THREE.MeshBasicMaterial({color: '#3b362c'}); + const pts = []; + for (let i = 0; i <= 22; i++) for (const s of [1, -1]) pts.push([-len / 2 + 0.012 + (i * (len - 0.024)) / 22, s * (wid / 2 - 0.006)]); + for (let i = 1; i < 10; i++) for (const s of [1, -1]) pts.push([s * (len / 2 - 0.006), -wid / 2 + (i * wid) / 10]); + const im = new THREE.InstancedMesh(hg, hm, pts.length); + const M = new THREE.Matrix4(); + pts.forEach(([x, z], i) => im.setMatrixAt(i, M.makeTranslation(x, h + 0.0003, z))); + g.add(im); + shell.push(im); + } + return {g, shell}; +} + +// ------------------------------------------------------------ a rope module: frame, 512 cores, copper weave, cover +export const ROPE = {len: 0.16, wid: 0.092, th: 0.028, cols: 32, rows: 8, layers: 2, px: 0.0036, pz: 0.0098, rCore: 0.00315, rHole: 0.0019, thick: 0.0024}; +const coreX = (c) => (c - (ROPE.cols - 1) / 2) * ROPE.px; +const coreZ = (r) => (r - (ROPE.rows - 1) / 2) * ROPE.pz; +const coreY = (l) => 0.0095 + l * 0.0098; +function ropeModule(i, mats) { + const g = new THREE.Group(); + const frame = std(COL.black, {roughness: 0.7}); + g.add(box(ROPE.len, 0.003, ROPE.wid, frame, 0, 0.0015, 0)); + for (const s of [1, -1]) g.add(box(ROPE.len, ROPE.th, 0.004, frame, 0, ROPE.th / 2, s * (ROPE.wid / 2 - 0.002))); + g.add(box(0.006, ROPE.th, ROPE.wid, frame, -ROPE.len / 2 + 0.003, ROPE.th / 2, 0)); + // the connector end, gold pins + const conn = box(0.01, ROPE.th * 0.8, ROPE.wid * 0.86, std(COL.black), ROPE.len / 2 - 0.005, ROPE.th * 0.42, 0); + g.add(conn); + const pinG = new THREE.CylinderGeometry(0.0005, 0.0005, 0.006, 6); + pinG.rotateZ(Math.PI / 2); + const pins = new THREE.InstancedMesh(pinG, mats.gold, 48); + const M = new THREE.Matrix4(); + for (let k = 0; k < 48; k++) pins.setMatrixAt(k, M.makeTranslation(ROPE.len / 2 + 0.002, 0.008 + (k % 2) * 0.008, -ROPE.wid * 0.4 + Math.floor(k / 2) * (ROPE.wid * 0.8) / 23)); + g.add(pins); + // cordwood rows of resistors and diodes along both long edges (the photo's white combs) + const cw = new THREE.CylinderGeometry(0.0007, 0.0007, 0.009, 6); + const cwm = new THREE.InstancedMesh(cw, std('#e9e3d2', {roughness: 0.8}), 2 * 70); + for (let k = 0; k < 140; k++) { + const s = k < 70 ? 1 : -1; + const x = -0.068 + ((k % 70) * 0.136) / 69; + cwm.setMatrixAt(k, M.makeTranslation(x, 0.012, s * 0.036)); + } + g.add(cwm); + // the cores: 2 layers of 8 rows of 32, axis along the module (the wires run along it) + const cg = new THREE.TorusGeometry((ROPE.rCore + ROPE.rHole) / 2, (ROPE.rCore - ROPE.rHole) / 2, 6, 14); + cg.rotateY(Math.PI / 2); + cg.scale(ROPE.thick / (ROPE.rCore - ROPE.rHole), 1, 1); + const cores = new THREE.InstancedMesh(cg, mats.ferrite, ROPE.cols * ROPE.rows * ROPE.layers); + let k = 0; + for (let l = 0; l < ROPE.layers; l++) for (let r = 0; r < ROPE.rows; r++) for (let c = 0; c < ROPE.cols; c++) cores.setMatrixAt(k++, M.makeTranslation(coreX(c), coreY(l), coreZ(r))); + cores.castShadow = true; + g.add(cores); + // the 192 lines of each row: a bundle through the cores and a looser one round them + const wl = ROPE.cols * ROPE.px + 0.01; + const thr = new THREE.CylinderGeometry(0.0014, 0.0014, wl, 8, 1, true); + thr.rotateZ(Math.PI / 2); + const aro = new THREE.CylinderGeometry(0.0039, 0.0039, wl, 14, 1, true); + aro.rotateZ(Math.PI / 2); + const bundles = new THREE.InstancedMesh(thr, mats.copper, ROPE.rows * ROPE.layers); + const sheath = new THREE.InstancedMesh(aro, mats.copperSheath, ROPE.rows * ROPE.layers); + k = 0; + for (let l = 0; l < ROPE.layers; l++) + for (let r = 0; r < ROPE.rows; r++) { + bundles.setMatrixAt(k, M.makeTranslation(0, coreY(l), coreZ(r))); + sheath.setMatrixAt(k++, M.makeTranslation(0, coreY(l), coreZ(r))); + } + g.add(bundles, sheath); + // the cover and its label + const cover = new THREE.Group(); + const cm = std(COL.magGreen, {roughness: 0.55}); + cover.add(box(ROPE.len - 0.004, 0.0022, ROPE.wid - 0.002, cm, 0, 0, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(0.11, 0.014), new THREE.MeshStandardMaterial({map: labelTex(`FIXED MEMORY MODULE B${i + 1}`, {px: 34, color: '#3b3a33'}), transparent: true, roughness: 0.8})); + lab.rotation.x = -Math.PI / 2; + lab.position.set(-0.01, 0.00115, 0); + cover.add(lab); + cover.position.y = ROPE.th + 0.0011; + g.add(cover); + return {g, cover, cores, conn, bundles, sheath}; +} + +// ------------------------------------------------------------ the detailed row inside module B1: 9 cores, 16 sense lines +const DET_N = 9; +function ropeDetail(mats) { + const g = new THREE.Group(); + const sel = ROPE_WORDS.find((w) => w.op === 'CAF NO.CORES'); + const P = ropePlace(sel.bank, sel.addr); + const row = Math.floor(P.core / ROPE.cols); + const layer = Math.floor(row / ROPE.rows); + const r = row % ROPE.rows; + const c0 = (P.core % ROPE.cols) - (DET_N - 1) / 2; + const y = coreY(layer); + const z = coreZ(r); + // the words held on this strand by the 9 cores: consecutive addresses [L] + const words = []; + for (let k = 0; k < DET_N; k++) { + const addr = sel.addr + (k - (DET_N - 1) / 2); + const w = wordAt(1, addr); + words.push({addr, word: w, bits: w === null ? null : bits16(w)}); + } + const cg = new THREE.TorusGeometry((ROPE.rCore + ROPE.rHole) / 2, (ROPE.rCore - ROPE.rHole) / 2, 18, 40); + cg.rotateY(Math.PI / 2); + cg.scale(ROPE.thick / (ROPE.rCore - ROPE.rHole), 1, 1); + const cores = []; + for (let k = 0; k < DET_N; k++) { + const m = new THREE.Mesh(cg, mats.coreSel.clone()); + m.position.set(coreX(c0 + k), y, z); + m.castShadow = true; + g.add(m); + cores.push(m); + } + // a sense line: through the hole of a core (a 1) or over it (a 0); 16 lines, the bits of this strand + const xs = (k) => coreX(c0 + k); + const throughAt = (j) => [(-1.5 + (j % 4)) * 0.00042, (-1.5 + Math.floor(j / 4)) * 0.00042]; + const aroundAt = (j) => { + const a = Math.PI * (0.12 + (0.76 * j) / 15); + const R = 0.0046 + (j % 2) * 0.0005; + return [Math.cos(a) * R, Math.sin(a) * R]; + }; + const wires = []; + const tubeR = 0.00012; + for (let j = 0; j < 16; j++) { + const pts = []; + const x0 = xs(0) - ROPE.px * 0.9; + const x1 = xs(DET_N - 1) + ROPE.px * 0.9; + const P0 = (k) => (words[k].bits && words[k].bits[j] ? throughAt(j) : aroundAt(j)); + const first = P0(0); + pts.push(new V3(x0 - 0.004, y + first[1] * 0.5 + 0.0006, z + first[0])); + for (let k = 0; k < DET_N; k++) { + const [dz, dy] = P0(k); + pts.push(new V3(xs(k) - ROPE.thick * 0.75, y + dy, z + dz)); + pts.push(new V3(xs(k) + ROPE.thick * 0.75, y + dy, z + dz)); + } + const last = P0(DET_N - 1); + pts.push(new V3(x1 + 0.004, y + last[1] * 0.5 + 0.0006, z + last[0])); + const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal'); + const m = new THREE.Mesh(new THREE.TubeGeometry(curve, 220, tubeR, 6), mats.wire.clone()); + g.add(m); + wires.push(m); + } + // the other 176 lines (11 other strands): faint bundles through and round + const wl = (DET_N + 1.8) * ROPE.px; + const others = new THREE.Mesh(new THREE.CylinderGeometry(0.00125, 0.00125, wl, 16, 1, true).rotateZ(Math.PI / 2), mats.copperDim); + others.position.set((xs(0) + xs(DET_N - 1)) / 2, y - 0.0003, z); + g.add(others); + // inhibit lines (the address decode) below, and the set / reset line + const inhib = []; + for (let q = 0; q < 4; q++) { + const pts = []; + for (let k = 0; k < DET_N; k++) { + const thru = ((k + q) % 3 !== 0) !== (k === (DET_N - 1) / 2) || (k === (DET_N - 1) / 2 && false); + const on = k === (DET_N - 1) / 2 ? true : thru; + const dy = on ? -0.0011 - q * 0.00022 : -0.0047 - q * 0.0004; + pts.push(new V3(xs(k) - ROPE.thick * 0.7, y + dy, z + (q - 1.5) * 0.0005)); + pts.push(new V3(xs(k) + ROPE.thick * 0.7, y + dy, z + (q - 1.5) * 0.0005)); + } + const m = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts, false, 'centripetal'), 160, 0.00013, 5), mats.inhibit.clone()); + g.add(m); + inhib.push(m); + } + const setPts = [new V3(xs(0) - 0.005, y + 0.0012, z - 0.0011), new V3(xs(DET_N - 1) + 0.005, y + 0.0012, z - 0.0011)]; + const setLine = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(setPts), 4, 0.00017, 6), mats.inhibit.clone()); + g.add(setLine); + return {g, cores, wires, inhib, setLine, words, sel, place: P, layer, row: r, c0, anchor: new V3(xs((DET_N - 1) / 2), y, z)}; +} + +// ------------------------------------------------------------ the erasable plane inside B12 +export const ERAS = {cols: 34, rows: 11, px: 0.0029, py: 0.0029, sel: [17, 5]}; +function erasPlane(mats) { + const g = new THREE.Group(); + const cg = new THREE.TorusGeometry(0.00072, 0.00026, 8, 18); + const cores = new THREE.InstancedMesh(cg, mats.ferrite, ERAS.cols * ERAS.rows); + const M = new THREE.Matrix4(); + const Q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI / 2, Math.PI / 4)); + const S = new V3(1, 1, 1); + const P = new V3(); + const pos = (c, r) => P.set((c - (ERAS.cols - 1) / 2) * ERAS.px, (r - (ERAS.rows - 1) / 2) * ERAS.py, 0); + let k = 0; + for (let r = 0; r < ERAS.rows; r++) for (let c = 0; c < ERAS.cols; c++) cores.setMatrixAt(k++, M.compose(pos(c, r), Q, S)); + g.add(cores); + // X lines (rows), Y lines (columns), the sense line zig-zag, the inhibit line + const W = ERAS.cols * ERAS.px; + const H = ERAS.rows * ERAS.py; + const xl = []; + const yl = []; + for (let r = 0; r < ERAS.rows; r++) { + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.00005, 0.00005, W + 0.004, 5).rotateZ(Math.PI / 2), mats.fine.clone()); + m.position.y = (r - (ERAS.rows - 1) / 2) * ERAS.py; + g.add(m); + xl.push(m); + } + for (let c = 0; c < ERAS.cols; c++) { + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.00005, 0.00005, H + 0.004, 5), mats.fine.clone()); + m.position.x = (c - (ERAS.cols - 1) / 2) * ERAS.px; + m.position.z = 0.0001; + g.add(m); + yl.push(m); + } + const zz = []; + for (let r = 0; r <= ERAS.rows; r++) zz.push(new V3((r % 2 ? 1 : -1) * (W / 2 + 0.0015), (r - ERAS.rows / 2) * ERAS.py, -0.00012)); + const sense = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(zz, false, 'catmullrom', 0.1), 260, 0.00006, 5), mats.fine.clone()); + g.add(sense); + // the selected core, on its own, with its magnetisation mark + const [sc, sr] = ERAS.sel; + const selCore = new THREE.Mesh(new THREE.TorusGeometry(0.00072, 0.00028, 14, 30), mats.erasSel); + selCore.position.copy(pos(sc, sr)); + selCore.quaternion.copy(Q); + g.add(selCore); + const mark = new THREE.Mesh(new THREE.SphereGeometry(0.0003, 10, 8), mats.mark); + g.add(mark); + const backing = new THREE.Mesh(new THREE.PlaneGeometry(W + 0.01, H + 0.01), std('#b9ae92', {roughness: 0.9})); + backing.position.z = -0.0014; + backing.receiveShadow = true; + g.add(backing); + return {g, xl, yl, sense, selCore, mark, centre: pos(sc, sr).clone()}; +} + +// ------------------------------------------------------------ the DSKY: face plate, lamps, EL panel, 19 keys +export const KEY_POS = (() => { + const out = {}; + const col = (i) => (i - 3) * 0.0262; + const row = (j) => -0.024 - j * 0.0262; + out.VERB = [col(0), row(0.5)]; + out.NOUN = [col(0), row(1.5)]; + ['+', '7', '8', '9', 'CLR'].forEach((k, i) => (out[k] = [col(i + 1), row(0)])); + ['-', '4', '5', '6', 'PRO'].forEach((k, i) => (out[k] = [col(i + 1), row(1)])); + ['0', '1', '2', '3', 'KEY REL'].forEach((k, i) => (out[k] = [col(i + 1), row(2)])); + out.ENTR = [col(6), row(0.5)]; + out.RSET = [col(6), row(1.5)]; + return out; +})(); +const keyLabel = (k) => (k === '-' ? '−' : k); +function dskyUnit(mats) { + const g = new THREE.Group(); + const S = L.dskyS; + const D = L.dskyD; + const body = std(COL.dsky, {roughness: 0.55, metalness: 0.25}); + const shell = box(S, S, D, body, 0, S / 2, 0); + g.add(shell); + // back connector block and side rails (the photo's mounting flanges) + g.add(box(S * 0.86, S * 0.9, 0.03, std('#2f3133'), 0, S / 2, -D / 2 - 0.015)); + for (const s of [1, -1]) g.add(box(0.006, S * 0.96, D * 0.9, std('#9a9b9c', {metalness: 0.4}), s * (S / 2 + 0.003), S / 2, -0.004)); + // the face: plate, lamp window, EL window, keyboard well + const plate = new THREE.CanvasTexture(faceCanvas()); + plate.colorSpace = THREE.SRGBColorSpace; + plate.anisotropy = 8; + const face = new THREE.Mesh(new THREE.PlaneGeometry(S, S), new THREE.MeshStandardMaterial({map: plate, roughness: 0.6, metalness: 0.1})); + face.position.set(0, S / 2, D / 2 + 0.0004); + face.receiveShadow = true; + g.add(face); + // the lamp panel and the EL panel: canvases redrawn when their state changes + const lampC = document.createElement('canvas'); + lampC.width = 420; + lampC.height = 470; + const lampT = new THREE.CanvasTexture(lampC); + lampT.colorSpace = THREE.SRGBColorSpace; + lampT.anisotropy = 8; + const lw = 0.08; + const lh = 0.0895; + const lamp = new THREE.Mesh(new THREE.PlaneGeometry(lw, lh), new THREE.MeshBasicMaterial({map: lampT, toneMapped: false})); + lamp.position.set(-0.048, S / 2 + 0.05, D / 2 + 0.0035); + g.add(lamp); + g.add(box(lw + 0.006, lh + 0.006, 0.006, std('#55575a'), -0.048, S / 2 + 0.05, D / 2 + 0.0002)); + const elC = document.createElement('canvas'); + elC.width = 400; + elC.height = 470; + const elT = new THREE.CanvasTexture(elC); + elT.colorSpace = THREE.SRGBColorSpace; + elT.anisotropy = 8; + const el = new THREE.Mesh(new THREE.PlaneGeometry(lw, lh), new THREE.MeshBasicMaterial({map: elT, toneMapped: false})); + el.position.set(0.048, S / 2 + 0.05, D / 2 + 0.0035); + g.add(el); + g.add(box(lw + 0.006, lh + 0.006, 0.006, std('#55575a'), 0.048, S / 2 + 0.05, D / 2 + 0.0002)); + // keys + const keys = {}; + const kg = new THREE.BoxGeometry(0.0218, 0.0218, 0.007); + for (const [k, [x, y]] of Object.entries(KEY_POS)) { + const t = labelTex(keyLabel(k), {w: 128, h: 128, px: k.length > 3 ? 34 : k.length > 1 ? 40 : 62, color: '#e9e6dc', bg: COL.key, weight: 600, spacing: 1}); + if (k === 'KEY REL') { + const c = t.image.getContext('2d'); + c.fillStyle = COL.key; + c.fillRect(0, 0, 128, 128); + c.fillStyle = '#e9e6dc'; + c.font = font(600, 36); + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.fillText('KEY', 64, 46); + c.fillText('REL', 64, 84); + t.needsUpdate = true; + } + const side = std(COL.key, {roughness: 0.5}); + const front = new THREE.MeshStandardMaterial({map: t, roughness: 0.5}); + const m = new THREE.Mesh(kg, [side, side, side, side, front, side]); + m.position.set(x, S / 2 + y, D / 2 + 0.0035); + m.castShadow = true; + m.userData.key = k; + g.add(m); + keys[k] = m; + } + return {g, keys, lampC, lampT, elC, elT, face, el, lamp, lastLamp: '', lastEl: ''}; +} +function faceCanvas() { + const c = document.createElement('canvas'); + c.width = 1024; + c.height = 1024; + const g = c.getContext('2d'); + g.fillStyle = '#8f9194'; + g.fillRect(0, 0, 1024, 1024); + // keyboard well + g.fillStyle = '#7d7f82'; + g.fillRect(24, 548, 976, 452); + g.strokeStyle = 'rgba(0,0,0,.25)'; + g.lineWidth = 3; + g.strokeRect(24, 548, 976, 452); + // screws + g.fillStyle = '#5d5f62'; + for (const [x, y] of [[40, 40], [984, 40], [40, 520], [984, 520], [512, 520], [40, 984], [984, 984]]) { + g.beginPath(); + g.arc(x, y, 9, 0, 7); + g.fill(); + } + // nameplate + g.fillStyle = '#6f7174'; + g.fillRect(392, 14, 240, 22); + g.fillStyle = '#d9d7cf'; + g.font = font(600, 17); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('DISPLAY & KEYBOARD', 512, 26); + return c; +} +// lamp panel: 2 columns of 7 [D]; lit lamps: white (status) or amber (caution) +const CAUTION = new Set(['TEMP', 'GIMBAL LOCK', 'PROG', 'RESTART', 'TRACKER', 'ALT', 'VEL']); +function drawLamps(d, lamps) { + drawLampPanel(d.lampC.getContext('2d'), d.lampC.width, d.lampC.height, lamps); + d.lampT.needsUpdate = true; +} +export function drawLampPanel(g, W, H, lamps) { + g.fillStyle = '#4b4d50'; + g.fillRect(0, 0, W, H); + const cw = (W - 30) / 2; + const ch = (H - 40) / 7; + LAMPS.forEach((rowL, r) => + rowL.forEach((name, c) => { + const x = 10 + c * (cw + 10); + const y = 10 + r * (ch + 3.3); + const on = name && lamps[name]; + g.fillStyle = on ? (CAUTION.has(name) ? AMBER : '#f4f0e0') : '#a6a59c'; + g.fillRect(x, y, cw, ch); + if (!name) return; + g.fillStyle = on ? '#2b2210' : '#6e6d66'; + g.font = font(700, name.length > 9 ? 25 : 29); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + if ('letterSpacing' in g) g.letterSpacing = '1px'; + const parts = name.length > 9 ? name.split(' ') : [name]; + parts.forEach((p, i) => g.fillText(p, x + cw / 2, y + ch / 2 + (i - (parts.length - 1) / 2) * 24 + 2)); + }), + ); +} +// the EL panel: COMP ACTY, PROG, VERB, NOUN, three signed 5-digit registers, green on black +export function drawEL(g, W, H, s, green = ACCENT) { + const off = 'rgba(53,194,106,0.06)'; + g.fillStyle = '#0d0f0e'; + g.fillRect(0, 0, W, H); + const lab = (t, x, y, w, on = true) => { + g.fillStyle = on ? green : '#0d0f0e'; + g.fillRect(x, y, w, 26); + g.fillStyle = on ? '#0d0f0e' : green; + g.font = font(700, 22); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + if ('letterSpacing' in g) g.letterSpacing = '1px'; + g.fillText(t, x + w / 2, y + 14); + }; + // COMP ACTY + if (s.compActy) { + g.fillStyle = green; + g.fillRect(22, 18, 140, 92); + g.fillStyle = '#0d0f0e'; + } else g.fillStyle = 'rgba(53,194,106,0.12)'; + g.font = font(700, 30); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('COMP', 92, 50); + g.fillText('ACTY', 92, 82); + lab('PROG', 252, 16, 120); + lab('VERB', 22, 128, 120); + lab('NOUN', 252, 128, 120); + const dw = 46; + const dh = 70; + drawDigits(g, s.prog, 262, 50, dw, dh, 10, green, off); + drawDigits(g, s.verb, 32, 162, dw, dh, 10, green, off); + drawDigits(g, s.noun, 262, 162, dw, dh, 10, green, off); + for (let r = 0; r < 3; r++) { + const y = 252 + r * 72; + g.fillStyle = 'rgba(53,194,106,0.55)'; + g.fillRect(22, y - 6, W - 44, 3); + drawDigits(g, s.reg[r], 30, y + 2, 46, 60, 9.5, green, off); + } +} +const DIG_STATE = (s) => `${s.compActy}|${s.prog}|${s.verb}|${s.noun}|${s.reg.join('|')}`; + +// ------------------------------------------------------------ a logic module's face: 60 flat-packs, and the enlarged gate +function flatpacks(n, w, h, mats) { + const fg = new THREE.BoxGeometry(0.0064, 0.0036, 0.0012); + const im = new THREE.InstancedMesh(fg, mats.flatpack, n); + const lg = new THREE.BoxGeometry(0.0088, 0.0002, 0.0004); + const leads = new THREE.InstancedMesh(lg, mats.gold, n * 5); + const M = new THREE.Matrix4(); + const per = n / 2; + for (let k = 0; k < n; k++) { + const x = -w / 2 + 0.006 + ((k % per) * (w - 0.012)) / (per - 1); + const y = (Math.floor(k / per) ? -1 : 1) * h * 0.2; + im.setMatrixAt(k, M.makeTranslation(x, y, 0)); + for (let q = 0; q < 5; q++) leads.setMatrixAt(k * 5 + q, M.makeTranslation(x, y - 0.0013 + q * 0.00065, 0.0004)); + } + const g = new THREE.Group(); + g.add(im, leads); + return g; +} + +// ------------------------------------------------------------ the machine +export function buildMachine() { + const root = new THREE.Group(); + const mats = { + mag: std(COL.mag, {roughness: 0.58, metalness: 0.35}), + magB: std(COL.magGreen, {roughness: 0.6, metalness: 0.3}), + strip: std(COL.strip, {roughness: 0.75}), + gold: std(COL.gold, {roughness: 0.42, metalness: 0.8}), + ferrite: std(COL.ferrite, {roughness: 0.5, metalness: 0.2}), + ferriteHi: std('#26231f', {roughness: 0.42, metalness: 0.25}), + coreSel: std('#26231f', {roughness: 0.42, metalness: 0.25, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + copper: std(COL.copper, {roughness: 0.45, metalness: 0.7}), + copperSheath: std('#9c5f37', {roughness: 0.6, metalness: 0.5, transparent: true, opacity: 0.55, depthWrite: false}), + copperDim: std('#8e5a36', {roughness: 0.6, metalness: 0.5, transparent: true, opacity: 0.5, depthWrite: false}), + wire: std('#c07a44', {roughness: 0.4, metalness: 0.7, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + inhibit: std('#6f6b63', {roughness: 0.5, metalness: 0.4, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + mark: new THREE.MeshBasicMaterial({color: '#f4efdf'}), + fine: std('#7d5233', {roughness: 0.5, metalness: 0.6, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + erasSel: std('#26231f', {roughness: 0.42, metalness: 0.25, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + flatpack: std('#1f1f22', {roughness: 0.4, metalness: 0.3}), + black: std(COL.black, {roughness: 0.65}), + }; + const clipPlane = new THREE.Plane(new V3(0, 0, -1), 0.02); + const shells = []; + const A = {}; + + // ------------------------------------------------ tray A: logic + const agc = new THREE.Group(); + agc.position.x = L.x0; + root.add(agc); + const ta = tray({len: L.len, wid: L.wid, h: L.trayH, mat: mats.mag, dark: COL.magDark, holes: true}); + agc.add(ta.g); + shells.push(...ta.shell); + // end connector (the gold field on the photo's front end) and its cover plate + const connA = new THREE.Group(); + connA.position.set(L.len / 2 + 0.004, 0.042, 0); + const field = new THREE.Mesh(new THREE.PlaneGeometry(0.2, 0.026), std('#8a7440', {roughness: 0.5, metalness: 0.7, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0})); + field.rotation.y = Math.PI / 2; + connA.add(field); + connA.add(box(0.005, 0.034, 0.23, mats.magB, -0.002, 0, 0)); + agc.add(connA); + const strips = []; + const SW = 0.0136; + const stripLen = 0.255; + const groups = [ + {x: -0.148, n: 14, from: 0}, + {x: 0.142, n: 17, from: 14}, + ]; + const label = (name) => { + const t = labelTex(name, {w: 512, h: 40, px: 30, color: '#57534a', spacing: 4}); + return new THREE.MeshStandardMaterial({map: t, transparent: true, roughness: 0.8}); + }; + for (const gr of groups) + for (let i = 0; i < gr.n; i++) { + const k = gr.from + i; + const name = k < 24 ? `LOGIC MODULE A${k + 1}` : `INTERFACE MODULE A${k + 1}`; + const s = new THREE.Group(); + s.add(box(stripLen, 0.06, SW - 0.0012, mats.strip, 0, 0.03, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(stripLen * 0.62, 0.0046), label(name)); + lab.rotation.x = -Math.PI / 2; + lab.position.set(0, 0.0602, 0); + s.add(lab); + s.position.set(gr.x, 0.006, -L.wid / 2 + 0.016 + (i + 0.5) * ((L.wid - 0.032) / gr.n)); + agc.add(s); + strips.push(s); + } + // module A13 opened: its flat-packs on the face that comes up, and one enlarged gate + const logicK = 12; + const logicMod = strips[logicK]; + const fp = flatpacks(60, stripLen - 0.02, 0.05, mats); + fp.position.set(0, 0.03, SW / 2 + 0.0002); + logicMod.add(fp); + const bigGate = new THREE.Group(); + { + const b = box(0.064, 0.038, 0.012, mats.flatpack, 0, 0, 0); + bigGate.add(b); + for (let q = 0; q < 5; q++) + for (const s of [1, -1]) { + const lead = box(0.03, 0.0016, 0.004, mats.gold, s * 0.045, -0.014 + q * 0.007, 0); + bigGate.add(lead); + } + const t = labelTex('2 × NOR · 3 INPUTS', {w: 512, h: 96, px: 44, color: '#d9d3c0', spacing: 3}); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(0.052, 0.0098), new THREE.MeshBasicMaterial({map: t, transparent: true})); + lab.position.z = 0.0062; + bigGate.add(lab); + } + bigGate.visible = false; + agc.add(bigGate); + + // ------------------------------------------------ tray B: memory, the rope bay + const tb = new THREE.Group(); + agc.add(tb); + const tbt = tray({len: L.len, wid: L.wid, h: L.trayH, mat: mats.magB, dark: '#6f735f', holes: true}); + tb.add(tbt.g); + shells.push(...tbt.shell); + // X-ribbed plate, inside, over its part of the tray + const xr = new THREE.Group(); + { + const w = 0.14; + const d = L.wid - 0.02; + xr.add(box(w, 0.004, d, mats.magB, 0, 0.002, 0)); + const ang = Math.atan2(d, w); + const len = Math.hypot(w, d); + for (const s of [1, -1]) { + const r = box(len, 0.008, 0.006, mats.magB, 0, 0.006, 0); + r.rotation.y = s * ang; + xr.add(r); + } + xr.position.set(0.225, 0.055, 0); + } + tb.add(xr); + // memory electronics B7-B17, on edge, across the tray + const bNames = ['OSCILLATOR B7', 'ALARM B8', 'ERASABLE DRIVER B9-10', 'ERASABLE DRIVER B9-10', 'CURRENT SWITCH B11', 'ERASABLE MEMORY B12', 'SENSE AMPLIFIER B13-14', 'SENSE AMPLIFIER B13-14', 'STRAND SELECT B15', 'ROPE DRIVER B16-17', 'ROPE DRIVER B16-17']; + const bStrips = []; + bNames.forEach((name, i) => { + const s = new THREE.Group(); + const isE = name.startsWith('ERASABLE MEMORY'); + const lenB = 0.26; + s.add(box(lenB, 0.05, 0.0225, isE ? mats.black : mats.strip, 0, 0.025, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(lenB * 0.66, 0.0062), isE ? new THREE.MeshStandardMaterial({map: labelTex(name.replace('B12', 'MODULE B12'), {w: 512, h: 40, px: 30, color: '#e8e2cf', spacing: 4}), transparent: true}) : label(name.replace(/ B/, ' MODULE B'))); + lab.rotation.x = -Math.PI / 2; + lab.position.set(0, 0.0502, 0); + s.add(lab); + s.position.set(0.0, 0.034, -L.wid / 2 + 0.018 + (i + 0.5) * ((L.wid - 0.036) / 11)); + tb.add(s); + bStrips.push(s); + }); + const eras = bStrips[5]; + const plane = erasPlane(mats); + plane.g.position.set(0, 0.025, 0.0114); + plane.g.visible = false; + eras.add(plane.g); + // the rope bay, at the -x end: 3 across, 2 deep + const ropes = []; + const bayX = -L.len / 2 + 0.006 + ROPE.len / 2 + 0.004; + for (let i = 0; i < 6; i++) { + const m = ropeModule(i, mats); + const col = i % 3; + const lay = Math.floor(i / 3); + m.home = new V3(bayX, 0.052 - lay * 0.032, (col - 1) * (ROPE.wid + 0.006)); + m.g.position.copy(m.home); + tb.add(m.g); + ropes.push(m); + } + // module B1 (top layer, middle) is the one that comes out + const b1 = ropes[1]; + const det = ropeDetail(mats); + det.g.visible = false; + b1.g.add(det.g); + // windows on tray B's outer face over the rope bay (it faces up when the box is closed) + { + const wm = std('#4a4a40', {roughness: 0.8}); + for (let c = 0; c < 3; c++) { + const w = new THREE.Mesh(new THREE.PlaneGeometry(ROPE.len - 0.03, ROPE.wid - 0.024), wm); + w.rotation.x = Math.PI / 2; + w.position.set(bayX, -0.0003, (c - 1) * (ROPE.wid + 0.006)); + tb.add(w); + shells.push(w); + } + const xo = new THREE.Group(); + const w = 0.36; + const d = L.wid - 0.03; + const ang = Math.atan2(d, w); + for (const s of [1, -1]) { + const r = box(Math.hypot(w, d), 0.005, 0.009, mats.magB, 0, -0.0025, 0); + r.rotation.y = s * ang; + xo.add(r); + } + xo.position.set(0.1, 0, 0); + tb.add(xo); + shells.push(...xo.children); + } + + // ------------------------------------------------ the DSKY + const dsky = dskyUnit(mats); + dsky.g.position.copy(L.dsky); + dsky.g.rotation.y = L.dskyYaw; + root.add(dsky.g); + // a cable from the DSKY to the AGC's end connector + { + const pts = [new V3(L.dsky.x - 0.06, 0.05, L.dsky.z - 0.11), new V3(L.dsky.x - 0.1, 0.012, L.dsky.z - 0.2), new V3(0.18, 0.01, -0.1), new V3(L.x0 + L.len / 2 + 0.03, 0.03, -0.02)]; + const cab = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 60, 0.0075, 10), std('#3a3833', {roughness: 0.75})); + cab.castShadow = true; + root.add(cab); + } + + for (const m of shells) if (m.material) m.material = m.material.clone(); + const shellMats = [...new Set(shells.map((m) => m.material))]; + + // anchors for callouts (world space, filled by update) + const anchors = {}; + const tmp = new V3(); + const world = (obj, local) => obj.localToWorld(tmp.copy(local)).clone(); + let cutWas = ''; + + /** + * fx: {open (0 closed, 1 apart), rope (0 in, 1 out), ropeCover (0 on, 1 off), ropeDetail (0..1), read {phase, on}, + * eras (0 in, 1 up), erasRead {phase, bit, on}, logic (0 in, 1 up), gate (0..1), rr (0..1 connector glow), + * cut ('' | 'case'), dsky {lamps, el: {compActy, prog, verb, noun, reg}, press: {key: 0..1}}} + */ + function update(fx) { + // tray B: lift, flip about the long axis, slide beside A, lower + const o = fx.open; + const k1 = smooth(o / 0.28); + const k2 = smooth((o - 0.22) / 0.56); + const k3 = smooth((o - 0.72) / 0.28); + tb.rotation.x = Math.PI * (1 - k2); + const yClosed = 2 * L.trayH; + // local y 0 is the tray's outer face: at 2 x 85 mm when closed (flipped), on the floor when apart + tb.position.set(0, mix(yClosed, 0, k3) + L.liftY * (k1 - k3), L.openZ * k2); + // rope module B1: out along -x, then up and clear of the tray + const r1 = smooth(fx.rope / 0.6); + const r2 = smooth((fx.rope - 0.55) / 0.45); + b1.g.position.set(b1.home.x - 0.25 * r1, b1.home.y + 0.075 * r2, b1.home.z); + b1.cover.position.y = ROPE.th + 0.0011 + 0.05 * smooth(fx.ropeCover); + b1.cover.visible = fx.ropeCover < 0.98; + b1.cover.children.forEach((c) => c.material && ((c.material.transparent = true), (c.material.opacity = 1 - smooth((fx.ropeCover - 0.4) / 0.6)))); + const inside = fx.ropeDetail > 0.01; + if (det.g.visible !== inside) { + det.g.visible = inside; + b1.bundles.visible = !inside; + b1.sheath.visible = !inside; + // the 9 detailed cores replace their instances + for (let k = 0; k < DET_N; k++) { + const idx = (det.layer * ROPE.rows + det.row) * ROPE.cols + det.c0 + k; + b1.cores.setMatrixAt(idx, inside ? M0.makeScale(0, 0, 0) : M0.makeTranslation(coreX(det.c0 + k), coreY(det.layer), coreZ(det.row))); + } + b1.cores.instanceMatrix.needsUpdate = true; + } + // the read inside one MCT: inhibit lines, then the set, then the reset whose flux the sense lines catch + const rd = fx.read || {phase: -1}; + const ph = rd.phase; + const inh = ph >= 0 && ph < 0.45 ? smooth(ph / 0.06) * (1 - smooth((ph - 0.38) / 0.07)) : 0; + const setK = ph >= 0.12 && ph < 0.45 ? smooth((ph - 0.12) / 0.05) * (1 - smooth((ph - 0.36) / 0.06)) : 0; + const resK = ph >= 0.45 && ph < 0.95 ? smooth((ph - 0.45) / 0.05) * (1 - smooth((ph - 0.85) / 0.1)) : 0; + det.inhib.forEach((m) => (m.material.emissiveIntensity = inh * 0.75)); + det.setLine.material.emissiveIntensity = Math.max(setK, resK) * 0.75; + const ck = rd.core ?? (DET_N - 1) / 2; + const sel = det.words[ck].bits || []; + det.wires.forEach((m, j) => { + const lit = sel[j] ? resK : 0; + m.material.emissiveIntensity = lit * 0.65; + m.material.color.set(lit > 0.5 ? '#7fcf9c' : '#c07a44'); + }); + det.cores.forEach((m, k) => (m.material.emissiveIntensity = k === ck ? Math.max(setK, resK) * 0.55 : 0)); + // B12 rises; its cover window shows the plane + const e1 = smooth(fx.eras); + eras.position.y = 0.034 + 0.085 * e1; + plane.g.visible = fx.eras > 0.3; + const er = fx.erasRead || {phase: -1, bit: 1}; + const ep = er.phase; + // read: half currents on one X and one Y line drive the core to 0; the sense line sees a 1. Rewrite: back to 1 + const rdK = ep >= 0 && ep < 0.5 ? smooth(ep / 0.05) * (1 - smooth((ep - 0.4) / 0.08)) : 0; + const wrK = ep >= 0.55 && ep < 1 ? smooth((ep - 0.55) / 0.05) * (1 - smooth((ep - 0.9) / 0.08)) : 0; + const [sc, sr] = ERAS.sel; + plane.xl.forEach((m, i) => (m.material.emissiveIntensity = i === sr ? Math.max(rdK, wrK) * 0.9 : 0)); + plane.yl.forEach((m, i) => (m.material.emissiveIntensity = i === sc ? Math.max(rdK, wrK) * 0.9 : 0)); + plane.sense.material.emissiveIntensity = er.bit && ep >= 0.18 && ep < 0.45 ? 0.9 * (1 - smooth((ep - 0.35) / 0.1)) : 0; + // the magnetisation: 1 = mark at the top of the ring, 0 = bottom; it turns while the core switches + let ang = er.bit ? Math.PI / 2 : -Math.PI / 2; + if (ep >= 0) { + if (er.bit) { + const a = smooth((ep - 0.12) / 0.14); + const b = smooth((ep - 0.62) / 0.14); + ang = Math.PI / 2 - Math.PI * a + Math.PI * b; + } + } + const rr = 0.00072; + tmp.set(Math.cos(ang) * rr, Math.sin(ang) * rr, 0).applyQuaternion(plane.selCore.quaternion); + plane.mark.position.copy(plane.centre).add(tmp); + mats.erasSel.emissiveIntensity = Math.max(rdK, wrK) * 0.6 * (ep >= 0 ? 1 : 0); + // logic module A13 up, one gate enlarged in front of it + const l1 = smooth(fx.logic); + logicMod.position.y = 0.006 + 0.09 * l1; + bigGate.visible = fx.gate > 0.01; + { + const gk = smooth(fx.gate); + bigGate.position.set(logicMod.position.x + 0.03, 0.2 + 0.02 * gk, logicMod.position.z + 0.07); + bigGate.scale.setScalar(0.2 + 0.8 * gk); + } + // the radar's pulses into the interface connector + field.material.emissiveIntensity = (fx.rr || 0) * 0.6; + // section of the closed case + const cut = fx.cut === 'case' && o < 0.02 ? 'case' : ''; + if (cut !== cutWas) { + cutWas = cut; + for (const m of shellMats) { + m.clippingPlanes = cut ? [clipPlane] : []; + m.needsUpdate = true; + } + } + clipPlane.constant = 0.02; + // the DSKY + const d = fx.dsky; + if (d) { + const ls = JSON.stringify(d.lamps); + if (ls !== dsky.lastLamp) { + dsky.lastLamp = ls; + drawLamps(dsky, d.lamps); + } + const es = DIG_STATE(d.el); + if (es !== dsky.lastEl) { + dsky.lastEl = es; + drawEL(dsky.elC.getContext('2d'), dsky.elC.width, dsky.elC.height, d.el); + dsky.elT.needsUpdate = true; + } + for (const [k, m] of Object.entries(dsky.keys)) m.position.z = L.dskyD / 2 + 0.0035 - 0.0024 * (d.press?.[k] || 0); + } + // anchors + root.updateMatrixWorld(true); + anchors.trayA = world(agc, new V3(-0.05, 0.07, L.wid / 2)); + anchors.trayB = world(tb, new V3(0.05, 0.06, 0)); + anchors.logic = world(logicMod, new V3(0.05, 0.06, 0)); + anchors.gate = world(bigGate, new V3(0.02, 0.01, 0.006)); + anchors.rope = world(b1.g, new V3(0, ROPE.th, 0)); + anchors.ropeEnd = world(b1.g, new V3(ROPE.len / 2, ROPE.th * 0.5, 0)); + anchors.core = world(b1.g, det.anchor.clone()); + anchors.coreNext = world(b1.g, det.anchor.clone().add(new V3(ROPE.px * 3, 0.0045, 0))); + anchors.inhib = world(b1.g, det.anchor.clone().add(new V3(-ROPE.px * 3, -0.0045, 0))); + anchors.eras = world(eras, new V3(0.08, 0.05, 0)); + anchors.erasCore = world(eras, plane.centre.clone().add(new V3(0, 0.025, 0.0114))); + anchors.erasSense = world(eras, new V3(ERAS.cols * ERAS.px * 0.5 + 0.0015, 0.025, 0.0114)); + anchors.dsky = world(dsky.g, new V3(0, L.dskyS * 0.75, L.dskyD / 2)); + anchors.dskyEl = world(dsky.g, new V3(0.048, L.dskyS / 2 + 0.05, L.dskyD / 2)); + anchors.dskyKeys = world(dsky.g, new V3(0, L.dskyS / 2 - 0.05, L.dskyD / 2)); + anchors.dskyProg = world(dsky.g, new V3(-0.028, L.dskyS / 2 + 0.077, L.dskyD / 2)); + anchors.conn = world(connA, new V3(0, 0, 0)); + anchors.xrib = world(tb, new V3(0.225, 0.06, 0)); + anchors.erasMod = world(eras, new V3(-0.06, 0.05, 0)); + } + update({open: 0, rope: 0, ropeCover: 0, ropeDetail: 0, eras: 0, logic: 0, gate: 0}); + return {root, update, anchors, dsky, det, plane, b1, tb, agc, eras, logicMod, bigGate, shellMats}; +} diff --git a/machines/rope/agc-shared/console.js b/machines/rope/agc-shared/console.js new file mode 100644 index 0000000..112c5ba --- /dev/null +++ b/machines/rope/agc-shared/console.js @@ -0,0 +1,435 @@ +// Rope's overlay, drawn as a sheet of the MIT Instrumentation Lab's documentation and a DSKY: the mission clock top +// left in seven-segment figures, the DSKY's face below it (the same drawing as the 3D panel), the Executive (duty, +// core sets, VAC areas, one millisecond of memory cycles, the alarm) on the right, the working subsystem's detail +// bottom left, the operator's buttons bottom centre, the title block (cartouche) bottom right, a one-line caption and +// callouts with thin leaders into the machine. Fixed 1600 x 900 CSS layout; update(s) redraws every live part. +import {ACCENT, ACCENT_INK, AMBER, drawEL, drawLampPanel} from './agc.js'; +import {AGC, EXEC, MCT, MCT_PER_S, ADDS_PER_S, ROPE_WORDS, octal} from './machine.js'; +import {svgDigits, digitsWidth} from './seg7.js'; +import {FAMILY} from './fonts.js'; +import {nf, T as tr} from './i18n.js'; + +export const C = {paper: '#efe8d8', panel: 'rgba(241,235,221,.9)', ink: '#2b2925', mute: '#7a7062', faint: 'rgba(70,58,40,.32)', line: 'rgba(70,58,40,.55)', acc: ACCENT_INK, accEl: ACCENT, amber: AMBER, amberInk: '#a86a06', steal: '#9c4a2e'}; +export const SUBS = ['rope', 'eras', 'nor', 'clock', 'exec', 'dsky', 'iface']; +export const CUTS = ['case', 'tray', 'module']; +export const VIEWS = ['axo', 'dskyFace', 'ropeIn', 'graze']; +const CSS = ` +#app{font-family:${FAMILY};color:${C.ink};-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums} +.ui{position:absolute;inset:0;pointer-events:none} +.pn{position:absolute;background:${C.panel};border:1px solid ${C.line}} +.pn:before{content:'';position:absolute;inset:3px;border:.6px solid ${C.faint};pointer-events:none} +.hd{position:absolute;font-weight:700;font-size:15px;letter-spacing:2.2px;text-transform:uppercase;white-space:nowrap} +.sm{position:absolute;font-weight:500;font-size:12.5px;letter-spacing:1.3px;text-transform:uppercase;color:${C.mute};white-space:nowrap} +.tx{position:absolute;font-weight:500;font-size:14px;line-height:18px;letter-spacing:.3px} +.tb{position:absolute;height:28px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:14px;letter-spacing:1.6px;text-transform:uppercase;border:1px solid ${C.line};background:${C.panel};color:${C.ink};white-space:nowrap;box-sizing:border-box} +.tb.on{background:${C.ink};color:${C.paper}} +.tb.on:after{content:'';position:absolute;left:6px;right:6px;bottom:3px;height:2px;background:${C.accEl}} +.gl{position:absolute;font-weight:600;font-size:12px;letter-spacing:2px;color:${C.mute};text-transform:uppercase} +.call{position:absolute;white-space:nowrap;background:rgba(241,235,221,.88);padding:2px 8px 4px;margin:-2px -8px 0} +.call b{display:block;font-weight:700;font-size:28px;line-height:30px;letter-spacing:.6px;text-transform:uppercase} +.call span{display:block;font-weight:500;font-size:15px;letter-spacing:1.8px;color:${C.mute};text-transform:uppercase;margin-top:1px} +.cur{position:absolute;left:0;top:0;z-index:50;filter:drop-shadow(0 1px 2px rgba(40,30,10,.35))} +.ring{position:absolute;width:36px;height:36px;margin:-18px 0 0 -18px;border-radius:50%;border:2px solid ${C.acc};opacity:0;z-index:49} +.cap{position:absolute;left:380px;width:840px;top:756px;text-align:center;font-weight:600;font-size:21px;line-height:27px;letter-spacing:.5px} +.cap span{background:rgba(241,235,221,.9);padding:1px 8px;box-decoration-break:clone;-webkit-box-decoration-break:clone} +.alarm{position:absolute;background:${C.amber};color:#2a1d05;border:1px solid #6b4a0c} +`; +const el = (h) => { + const d = document.createElement('div'); + d.innerHTML = h.trim(); + return d.firstElementChild; +}; +const KIND_LABEL = () => ({servicer: tr('job:servicer'), monitor: tr('job:monitor'), lpd: tr('job:lpd'), charin: tr('job:charin')}); +export const pad = (n, w) => String(n).padStart(w, '0'); +/** GET as hh:mm:ss.cc */ +export function getText(s) { + const t = Math.max(0, s); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const sec = t % 60; + return `${pad(h, 3)}:${pad(m, 2)}:${pad(Math.floor(sec), 2)}.${pad(Math.floor((sec % 1) * 100), 2)}`; +} + +export function createConsole(app) { + const style = document.createElement('style'); + style.textContent = CSS; + document.head.appendChild(style); + const ui = el(`
`); + app.appendChild(ui); + const add = (h) => { + const n = el(h); + ui.appendChild(n); + return n; + }; + + // ------------------------------------------------------------ the mission clock + add(`
`); + add(`
${tr('clockTitle')}
`); + const getSvg = add(``); + add(`
${tr('mctSince')}
`); + const mctSvg = add(``); + const rateTx = add(`
`); + const phaseTx = add(`
`); + + // ------------------------------------------------------------ the DSKY, as on its face + add(`
`); + const lampCv = add(``); + const elCv = add(``); + add(`
${tr('dskyTitle')}
`); + const dskyTx = add(`
`); + + // ------------------------------------------------------------ the Executive + const EX = {x: 1262, y: 32, w: 304}; + add(`
`); + add(`
${tr('execTitle')}
`); + add(`
${tr('dutyTitle')}
`); + const dutySvg = add(``); + const dutyTot = add(``); + add(`
${tr('setsTitle')}
`); + const busyTx = add(`
`); + const setsSvg = add(``); + add(`
${tr('vacTitle')}
`); + const vacSvg = add(``); + add(`
${tr('stripTitle')}
`); + const stripSvg = add(``); + const alarmBox = add(``); + const restartTx = add(`
`); + + // ------------------------------------------------------------ the subsystem's detail + const DT = {x: 34, y: 452, w: 304, h: 326}; + add(`
`); + const detHd = add(`
`); + const detSvg = add(``); + + // ------------------------------------------------------------ buttons + const btn = {}; + const BX = 372; + add(`
${tr('grpSubs')}
`); + SUBS.forEach((k, i) => (btn[k] = add(`
${tr('sub:' + k)}
`))); + add(``); + const BY = 32; + const BXC = 372; + add(`
${tr('grpCuts')}
`); + CUTS.forEach((k, i) => (btn[k] = add(`
${tr('cut:' + k)}
`))); + const BXV = 712; + add(`
${tr('grpViews')}
`); + VIEWS.forEach((k, i) => (btn[k] = add(`
${tr('view:' + k)}
`))); + + // ------------------------------------------------------------ the title block + const CB = {x: 1262, y: 688, w: 304, h: 178}; + add(`
`); + const hl = (y) => add(`
`); + const vl = (x, y0, y1) => add(`
`); + hl(66); + hl(92); + hl(116); + vl(178, 116, CB.h); + add(`
${tr('title')}
`); + add(`
${tr('titleSub')}
`); + add(`
${tr('titleMission')}
`); + add(`
${tr('titleSources')}
`); + add(`
${tr('titleNot')}
`); + add(`
${tr('titleSheet')}
`); + add(`
${tr('titleFigs', nf(AGC.massKg), nf(AGC.powerW))}
`); + + // ------------------------------------------------------------ caption, callouts, the pointer + const cap = add(`
`); + const callSvg = add(``); + const callG = add(`
`); + const ring = add(`
`); + const cur = add(`
`); + + const centre = (n) => { + const l = parseFloat(n.style.left); + const t = parseFloat(n.style.top); + return [l + parseFloat(n.style.width) / 2, t + 14]; + }; + const spots = Object.fromEntries(Object.entries(btn).map(([k, n]) => [k, centre(n)])); + const pointer = (c) => { + cur.style.display = c.x < 0 ? 'none' : 'block'; + cur.style.transform = `translate(${(c.x - 3).toFixed(2)}px, ${(c.y - 2).toFixed(2)}px) scale(${c.press ? 0.88 : 1})`; + ring.style.left = `${c.ringX}px`; + ring.style.top = `${c.ringY}px`; + ring.style.opacity = String(c.ring * 0.85); + ring.style.transform = `scale(${(0.35 + 0.9 * (1 - c.ring)).toFixed(3)})`; + }; + + // caches so the SVGs are rebuilt only when they change + const last = {}; + const put = (key, node, html) => { + if (last[key] === html) return; + last[key] = html; + node.innerHTML = html; + }; + const txt = (x, y, s, o = {}) => + `${s}`; + const hatch = ``; + + // ------------------------------------------------------------ the detail panel, per subsystem + const drawDetail = (d) => { + const W = DT.w - 28; + let h = hatch; + if (d.kind === 'rope') { + // the word: 16 cells, filled as the sense amplifiers see them + const w = ROPE_WORDS[d.w]; + h += txt(0, 14, tr('ropeAddr', w.bank, octal(w.addr, 4), d.place.module + 1, d.place.core, d.place.strand), {size: 13.5, w: 600}); + h += txt(0, 36, w.op, {size: 26, w: 700, ls: 1.5}); + if (w.label) h += txt(W, 36, w.label, {size: 14, w: 600, anchor: 'end', fill: C.mute}); + const cw = W / 16; + for (let b = 0; b < 16; b++) { + const x = b * cw; + const seen = b < d.fill; + const v = w.bits[b]; + h += ``; + if (seen) h += svgDigits(String(v), x + cw / 2 - 5, 56, 10, 22, 0, v ? C.paper : C.ink, null, 0.05); + h += txt(x + cw / 2, 98, b === 15 ? 'P' : String(15 - b), {size: 10.5, w: 500, anchor: 'middle', fill: C.mute, ls: 0}); + } + h += txt(0, 124, tr('ropeOctal'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(d.fill >= 16 ? octal(w.word) : ' ', 0, 132, 18, 32, 5, C.ink, 'rgba(70,58,40,.08)'); + h += txt(W, 150, tr('ropeParity', w.parity), {size: 13, w: 600, anchor: 'end'}); + h += txt(0, 196, tr('ropePhase'), {size: 12.5, w: 500, fill: C.mute}); + const phases = [tr('ph:inhibit'), tr('ph:set'), tr('ph:reset'), tr('ph:strobe')]; + phases.forEach((p, i) => { + const on = d.stage === i; + h += ``; + h += txt((i * W) / 4 + W / 8, 222, p, {size: 13, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 1}); + }); + h += `
${tr('ropeNote', nf(AGC.fixedWords), AGC.ropeModules, nf(AGC.coresPerModule), AGC.senseLinesPerCore)}
`; + } else if (d.kind === 'eras') { + h += txt(0, 14, tr('erasAddr', d.set), {size: 13.5, w: 600}); + h += txt(0, 36, d.job ? tr('erasJob', KIND_LABEL()[d.job]) : tr('erasFree'), {size: 22, w: 700, ls: 1}); + const cw = W / 16; + for (let b = 0; b < 16; b++) { + const x = b * cw; + const v = d.bits[b]; + const isPlane = b === 1; + const shown = d.state === 'read' ? b < d.fill : d.state === 'gone' ? false : true; + h += ``; + if (shown) h += svgDigits(String(v), x + cw / 2 - 5, 56, 10, 22, 0, v ? C.paper : C.ink, null, 0.05); + h += txt(x + cw / 2, 98, b === 15 ? 'P' : String(15 - b), {size: 10.5, w: 500, anchor: 'middle', fill: isPlane ? C.acc : C.mute, ls: 0}); + } + h += txt(0, 124, tr('erasPlane'), {size: 12.5, w: 600, fill: C.acc}); + const steps = [tr('es:read'), tr('es:sense'), tr('es:rewrite')]; + steps.forEach((p, i) => { + const on = d.stage === i; + h += ``; + h += txt((i * W) / 3 + W / 6, 154, p, {size: 13, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 1}); + }); + h += `
${tr('erasNote', nf(AGC.erasableWords), nf(AGC.erasableWords * AGC.wordBits))}
`; + } else if (d.kind === 'nor') { + // RTL NOR: three transistors, one collector resistor; inputs from three timing pulses + const [a, b, c] = d.in; + const out = a || b || c ? 0 : 1; + const on = (v) => (v ? C.acc : C.ink); + h += txt(0, 14, tr('norHead'), {size: 13.5, w: 600}); + const y0 = 40; + h += `` + txt(236, y0 + 4, 'V+', {size: 12, w: 600}); + h += ``; + h += ``; + h += `` + txt(244, y0 + 57, `Q = ${out}`, {size: 16, w: 700, fill: on(out)}); + [a, b, c].forEach((v, i) => { + const x = 60 + i * 60; + const yb = y0 + 84; + h += ``; + h += ``; + h += ``; + h += ``; + h += ``; + h += txt(x - 34, yb + 4, 'ABC'[i], {size: 14, w: 700, anchor: 'end', fill: on(v)}) + txt(x - 19, yb + 24, String(v), {size: 13, w: 700, anchor: 'middle', fill: on(v)}); + }); + h += `` + txt(206, y0 + 118, '0 V', {size: 12, w: 600}); + // truth table of NOR 3 + const ty = 176; + h += txt(0, ty, 'A B C → Q', {size: 13, w: 700, ls: 2}); + for (let k = 0; k < 8; k++) { + const ia = (k >> 2) & 1; + const ib = (k >> 1) & 1; + const ic = k & 1; + const cur = ia === a && ib === b && ic === c; + const x = (k % 4) * 69; + const y = ty + 10 + Math.floor(k / 4) * 22; + h += ``; + h += txt(x + 33, y + 14, `${ia}${ib}${ic} → ${ia || ib || ic ? 0 : 1}`, {size: 13, w: 600, anchor: 'middle', fill: cur ? C.paper : C.ink, ls: 1}); + } + h += `
${tr('norNote', nf(AGC.ics), nf(AGC.ics * AGC.gatesPerIc))}
`; + } else if (d.kind === 'clock') { + h += txt(0, 14, tr('clockHead', nf(AGC.clockHz / 1e6, 3), nf(MCT * 1e6, 2)), {size: 13.5, w: 600}); + const lane = (W - 34) / 12; + for (let p = 0; p < 12; p++) { + const x = 34 + p * lane; + const on = d.pulse === p; + h += ``; + h += txt(x + lane / 2, 44, `T${p + 1}`, {size: 11.5, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 0}); + } + h += txt(0, 44, 'MCT', {size: 12, w: 700}); + // the waveform of the 1.024 MHz chain over one MCT + let path = ''; + for (let k = 0; k <= 24; k++) path += `${k ? 'L' : 'M'}${34 + (k * (W - 34)) / 24},${k % 2 ? 62 : 76}${k < 24 ? `L${34 + ((k + 1) * (W - 34)) / 24},${k % 2 ? 62 : 76}` : ''}`; + h += `` + txt(0, 74, '1 MHZ', {size: 11, w: 600}); + const px = 34 + d.phase * (W - 34); + h += ``; + h += txt(0, 110, tr('clockPulse'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(String(d.pulse + 1).padStart(2, ' '), 0, 118, 18, 32, 5, C.acc) + txt(50, 146, '/ 12', {size: 18, w: 700}); + h += `
${tr('clockNote', nf(MCT_PER_S), nf(ADDS_PER_S), nf(MCT * 2e6, 1))}
`; + } else if (d.kind === 'iface') { + h += txt(0, 14, tr('ifHead'), {size: 13.5, w: 600}); + const rows = [ + [tr('if:rr'), d.rr ? nf(EXEC.rrAngles * EXEC.rrPulses * (EXEC.tloss / (EXEC.rrAngles * EXEC.rrPulses * MCT))) : '0', d.rr], + [tr('if:imu'), '≈ 0', false], + [tr('if:lr'), d.lr ? tr('if:lrOn') : '—', false], + ]; + rows.forEach(([name, v, hot], i) => { + const y = 32 + i * 34; + h += ``; + h += txt(8, y + 19, name, {size: 13.5, w: 600}); + h += txt(W - 8, y + 19, v, {size: 15, w: 700, anchor: 'end', fill: hot ? C.steal : C.ink}); + }); + h += txt(0, 152, tr('ifTloss'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(nf((d.rr ? EXEC.tloss : 0) * 100, 1).replace(',', '.'), 0, 160, 18, 32, 5, d.rr ? C.steal : C.ink); + h += txt(110, 188, '%', {size: 20, w: 700}); + h += `
${tr('ifNote', nf(EXEC.rrPulses), nf(EXEC.tlossMax * 100, 2), nf(EXEC.rrAngles * EXEC.rrPulses * MCT * 100, 1))}
`; + } else if (d.kind === 'dsky') { + h += txt(0, 14, tr('dskyHead'), {size: 13.5, w: 600}); + h += `
${d.text}
`; + } else { + // exec: the job list with priorities + h += txt(0, 14, tr('exHead'), {size: 13.5, w: 600}); + h += `
${tr('exNote', EXEC.coreSets, EXEC.vacAreas, nf(EXEC.braking * 100), nf(EXEC.lrConvert * 100, 1), nf(EXEC.n68 * 100), nf(EXEC.tloss * 100))}
`; + } + return h; + }; + + return { + spots, + btn, + root: ui, + pointer, + lampCv, + elCv, + update(s) { + // clock + put('get', getSvg, svgDigits(getText(s.get), 0, 2, 18, 34, 4.5, C.ink, 'rgba(70,58,40,.045)')); + put('mct', mctSvg, svgDigits(nf(Math.floor(s.mct)).replace(/[^0-9]/g, ' '), 0, 2, 12, 22, 3.5, C.ink, null)); + put('rate', rateTx, s.rateText); + put('phase', phaseTx, `P${s.dsky.prog}`); + // DSKY + const dk = JSON.stringify(s.dsky); + if (dk !== last.dsky) { + last.dsky = dk; + drawLampPanel(lampCv.getContext('2d'), 420, 470, s.dsky.lamps); + drawEL(elCv.getContext('2d'), 400, 470, s.dsky); + } + put('dskyTx', dskyTx, s.dskyText); + // duty: stacked bar of what the machine is asked for, against 100 % + { + const W = EX.w - 28; + const X = (v) => (v / 1.1) * W; + const parts = s.duty.parts; + let h = hatch; + let x = 0; + for (const p of parts) { + const w = X(p.v); + h += ``; + x += w; + } + h += `` + txt(X(1) + 4, 12, '100', {size: 11, w: 700}); + const tot = s.duty.total; + parts.forEach((p, i) => { + const lx = (i % 2) * (W / 2); + const ly = 42 + Math.floor(i / 2) * 15; + h += `` + txt(lx + 13, ly + 9, `${tr('duty:' + p.k)} ${nf(p.v * 100, Math.abs(p.v * 100 - Math.round(p.v * 100)) > 0.05 ? 1 : 0)} %`, {size: 12, w: 600, ls: 0.5}); + }); + put('duty', dutySvg, h); + put('dutyTot', dutyTot, svgDigits(String(Math.round(tot * 100)).padStart(3, ' '), 0, 1, 13, 22, 3.5, tot > 1 ? C.amberInk : C.ink, 'rgba(70,58,40,.05)') + txt(56, 22, '%', {size: 16, w: 700})); + } + // core sets + { + const W = EX.w - 28; + let h = ''; + const kl = KIND_LABEL(); + s.sets.forEach((j, k) => { + const y = k * 26; + const run = j && j.run; + h += `` + txt(11, y + 17, String(k), {size: 14, w: 700, anchor: 'middle', fill: j ? C.paper : C.ink, ls: 0}); + h += ``; + if (j) h += txt(36, y + 17, kl[j.kind], {size: 14, w: 700, fill: run ? C.paper : C.ink, ls: 1.2}) + txt(W - 6, y + 17, j.age, {size: 12.5, w: 600, anchor: 'end', fill: run ? C.paper : C.mute, ls: 0.5}); + else h += txt(36, y + 17, tr('setFree'), {size: 13, w: 500, fill: C.mute, ls: 1.2}); + }); + h += txt(0, 8 * 26 + 2, '', {}); + put('sets', setsSvg, h); + } + { + let h = ''; + s.vacs.forEach((v, k) => { + h += `` + txt(k * 40 + 17, 17, `VAC${k + 1}`, {size: 11, w: 700, anchor: 'middle', fill: v ? C.paper : C.ink, ls: 0}); + }); + put('vac', vacSvg, h); + put('busyTx', busyTx, `${tr('busyOf', s.busy, EXEC.coreSets)}`); + } + // one millisecond of memory cycles: jobs, stolen, idle + { + const W = EX.w - 28; + const n = s.strip.length; + const cw = W / n; + let h = hatch; + for (let i = 0; i < n; i++) { + const v = s.strip[i]; + if (v === 's') h += ``; + else if (v === 'j') h += ``; + else h += ``; + } + if (s.stripAt >= 0) h += ``; + h += `` + txt(13, 39, tr('stripJob'), {size: 11.5, w: 600, ls: 0.6}); + h += `` + txt(109, 39, tr('stripSteal'), {size: 11.5, w: 600, ls: 0.6}); + h += `` + txt(203, 39, tr('stripIdle'), {size: 11.5, w: 600, ls: 0.6}); + put('strip', stripSvg, h); + } + // the alarm, then what the restart kept and dropped + { + const a = s.alarm; + if (a && a.k > 0.01) { + alarmBox.style.display = 'block'; + alarmBox.style.opacity = a.k.toFixed(2); + put( + 'alarm', + alarmBox, + `
${tr('alarmHead')}
+ ${svgDigits(String(a.code).padStart(4, '0'), 0, 2, 22, 44, 6, '#2a1d05')} +
${tr('alarm:' + a.code)}
+
${tr('restartKept', KIND_LABEL()[a.kept] ?? '—')}
${tr('restartDropped', a.dropped)}
`, + ); + put('restart', restartTx, ''); + } else { + alarmBox.style.display = 'none'; + put('restart', restartTx, s.execNote || ''); + } + } + // detail + put('detHd', detHd, tr('det:' + s.detail.kind)); + put('det', detSvg, drawDetail(s.detail)); + put('cap', cap, s.caption ? `${s.caption}` : ''); + // buttons + for (const [k, n] of Object.entries(btn)) { + const on = s.sub === k || s.cut === k || s.view === k; + if (n.classList.contains('on') !== on) n.classList.toggle('on', on); + } + // callouts + let lh = ''; + let ch = ''; + for (const c of s.callouts) { + if (c.o < 0.02 || !c.at) continue; + const [ax, ay] = c.at; + const [lx, ly] = c.label; + const right = c.w < 0; + const w = Math.abs(c.w); + const o = c.o.toFixed(2); + lh += ``; + ch += `
${c.name}${c.sub}
`; + } + put('callL', callSvg, lh); + put('callG', callG, ch); + pointer(s.cursor); + }, + }; +} +void digitsWidth; diff --git a/machines/rope/agc-shared/executive.js b/machines/rope/agc-shared/executive.js new file mode 100644 index 0000000..a34a57f --- /dev/null +++ b/machines/rope/agc-shared/executive.js @@ -0,0 +1,305 @@ +// The Executive's log, planned once: the LGC's jobs from PDI to touchdown, simulated in 1 ms steps, with the +// rendezvous radar's cycle steals taken off the top of every millisecond. Everything the DSKY and the Executive panel +// show is read from this log at the frame's mission time. No three.js. +// +// The jobs [E1] [E]: SERVICER every 2 s (needs a core set and a VAC area), its duty set by the phase (braking 85 %, +// +2 % for the landing radar once it locks, approach 90.5 %, P66 80 %); a Verb 16 monitor job every second while the +// crew has one up (3 %, lower priority than SERVICER); in P64 the landing-point-designator job every 2 s; one short +// keyboard job per key press. A job holds its core set from its request until it ends. When a request finds the 8 core +// sets (or the 5 VAC areas) busy: BAILOUT, alarm 1202 (1201), a software restart that keeps only the newest SERVICER, +// drops the rest and the Verb 16 monitor, and lights PROG. +import {CREW_KEYS, EVENTS, EXEC, DESCENT_END} from './machine.js'; + +export const DT = 0.001; +export const SAMPLE = 0.01; // the log keeps one sample every 10 ms +export const JOB = {servicer: 0, monitor: 1, lpd: 2, charin: 3}; +const PRIO = {servicer: 20, monitor: 10, lpd: 10, charin: 30}; // relative order only [E] +const NEEDS_VAC = {servicer: true, monitor: false, lpd: true, charin: false}; +const WORK = {monitor: EXEC.n68 * EXEC.monitorPeriod, lpd: EXEC.approachJobs, charin: 0.004}; +const LPD_START = 12; +const LR_RAMP = 24; // s for the radar's data to come fully into the navigation [E], calibrated on the first alarm + +/** the phase's program number at s */ +export const progAt = (s) => (s < EVENTS.p64 ? 63 : s < EVENTS.p66 ? 64 : s < EVENTS.contact + 3 ? 66 : 68); +/** SERVICER's duty (fraction of the machine) at s */ +export function servicerDuty(s) { + const p = progAt(s); + if (p === 63) return EXEC.braking + (s > EVENTS.alt ? EXEC.lrConvert * Math.min(1, (s - EVENTS.alt) / LR_RAMP) : 0); + if (p === 64) { + const k = Math.min(1, (s - EVENTS.p64) / (EVENTS.p66 - EVENTS.p64)); + return EXEC.approach[0] + (EXEC.approach[1] - EXEC.approach[0]) * k - EXEC.approachJobs / EXEC.servicerPeriod; + } + return EXEC.manual; +} + +// deterministic jitter on each SERVICER pass +const jit = (k) => { + const x = Math.sin(k * 12.9898 + 78.233) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +}; + +/** + * Runs the log. keys: [[s, key]] (the crew's, plus any the visitor adds); radar: the RR cycle steals on (as flown) or + * off. Returns the samples, every job, the alarms, the displays and the lamps. + */ +export function simulate({keys = CREW_KEYS, radar = true, end = DESCENT_END} = {}) { + const N = Math.ceil(end / SAMPLE) + 1; + const S = { + sets: new Uint8Array(N), // bit k: core set k busy + vacs: new Uint8Array(N), + run: new Int8Array(N), // the job running at the sample (JOB.*), -1 the dummy job + duty: new Float32Array(N), // demanded duty incl. the radar's steals + busy: new Uint8Array(N), // core sets in use + }; + const jobs = []; + const alarms = []; + const restarts = []; + const live = []; + const sets = new Array(EXEC.coreSets).fill(null); + const vacs = new Array(EXEC.vacAreas).fill(null); + const tloss = radar ? EXEC.tloss : 0; + // ------------------------------------------------ the DSKY's own state (a small PINBALL) + const kq = [...keys].sort((a, b) => a[0] - b[0]); + let ki = 0; + const disp = []; // display states over time + const lampEv = []; // [s, lamp, on] + let mode = ''; // '', 'verb', 'noun' + let vb = ''; + let nb = ''; + let verb = '06'; + let noun = '63'; + let monitor = false; + let failreg = [0, 0, 0]; + let prog = 63; + const keyEv = []; + const pushDisp = (s, x = {}) => disp.push({s, prog, verb, noun, mode, vb, nb, monitor, fail: [...failreg], ...x}); + pushDisp(0); + let oprErr = false; + const exec = (s) => { + const v = vb.padStart(2, '0'); + const n = nb.padStart(2, '0'); + mode = ''; + if ((v === '16' || v === '06') && ['63', '64', '68', '60'].includes(n)) { + verb = v; + noun = n; + monitor = v === '16'; + } else if (v === '05' && n === '09') { + verb = v; + noun = n; + monitor = false; + } else if (v === '16' && n === '36') { + verb = v; + noun = n; + monitor = true; + } else { + oprErr = true; + lampEv.push([s, 'OPR ERR', 1]); + } + vb = v; + nb = n; + }; + const key = (s, k) => { + keyEv.push([s, k]); + if (k === 'VERB') { + mode = 'verb'; + vb = ''; + } else if (k === 'NOUN') { + mode = 'noun'; + nb = ''; + } else if (/^\d$/.test(k)) { + if (mode === 'verb' && vb.length < 2) vb += k; + else if (mode === 'noun' && nb.length < 2) nb += k; + else { + oprErr = true; + lampEv.push([s, 'OPR ERR', 1]); + } + } else if (k === 'CLR') { + if (mode === 'verb') vb = ''; + if (mode === 'noun') nb = ''; + } else if (k === 'ENTR') { + if (mode) exec(s); + } else if (k === 'RSET') { + lampEv.push([s, 'PROG', 0]); + if (oprErr) lampEv.push([s, 'OPR ERR', 0]); + oprErr = false; + } else if (k === 'KEY REL') { + monitor = false; + mode = ''; + progDisplay(); + } + pushDisp(s); + }; + const progDisplay = () => { + verb = '06'; + noun = prog === 63 ? '63' : prog === 64 ? '64' : '60'; + monitor = false; + }; + + // ------------------------------------------------ the Executive + let nextServ = 0.02; + let nextMon = Infinity; + let nextLpd = Infinity; + let kServ = 0; + let id = 0; + const request = (s, kind, work) => { + const set = sets.indexOf(null); + const vac = NEEDS_VAC[kind] ? vacs.indexOf(null) : -2; + if (set < 0 || vac === -1) return bailout(s, set < 0 ? 1202 : 1201, kind); + const j = {id: id++, kind, prio: PRIO[kind], t0: s, t1: null, work, left: work, set, vac, fate: ''}; + sets[set] = j; + if (vac >= 0) vacs[vac] = j; + jobs.push(j); + live.push(j); + return j; + }; + const free = (j, s, fate) => { + j.t1 = s; + j.fate = fate; + sets[j.set] = null; + if (j.vac >= 0) vacs[j.vac] = null; + live.splice(live.indexOf(j), 1); + }; + const bailout = (s, code, kind) => { + const busy = sets.filter(Boolean).length; + alarms.push({s, code, busy, vacBusy: vacs.filter(Boolean).length, kind}); + if (!failreg[0]) failreg[0] = code; + else if (!failreg[1]) failreg[1] = code; + failreg[2] = code; + lampEv.push([s, 'PROG', 1]); + // the restart: only the newest SERVICER survives, restarted from its restart point + const newest = [...live].reverse().find((j) => j.kind === 'servicer'); + const kept = []; + const dropped = []; + for (const j of [...live]) { + if (j === newest) continue; + dropped.push({kind: j.kind, t0: j.t0}); + free(j, s, 'flushed'); + } + if (newest) { + kept.push({kind: newest.kind, t0: newest.t0}); + newest.left = newest.work; + } + if (monitor) { + monitor = false; + nextMon = Infinity; + } + progDisplay(); + mode = ''; + restarts.push({s, code, kept, dropped}); + pushDisp(s, {restart: true}); + return null; + }; + + const steps = Math.ceil(end / DT); + let si = 0; + for (let i = 0; i <= steps; i++) { + const s = i * DT; + // keys + while (ki < kq.length && kq[ki][0] <= s) { + const [ks, k] = kq[ki++]; + request(ks, 'charin', WORK.charin); + const wasMon = monitor; + key(ks, k); + if (monitor && !wasMon) nextMon = ks + 0.05; + if (!monitor) nextMon = Infinity; + } + // program changes + const p = progAt(s); + if (p !== prog) { + prog = p; + if (p === 64) { + nextLpd = s + LPD_START; // after the pitchover [E] + } else nextLpd = Infinity; + nextMon = Infinity; + progDisplay(); + mode = ''; + pushDisp(s); + } + // the waitlist + if (s >= nextServ) { + const w = EXEC.servicerPeriod * servicerDuty(s) * (1 + 0.025 * jit(kServ++)); + request(s, 'servicer', w); + nextServ += EXEC.servicerPeriod; + } + if (s >= nextMon) { + if (monitor) request(s, 'monitor', WORK.monitor); + nextMon = monitor ? nextMon + EXEC.monitorPeriod : Infinity; + } + if (s >= nextLpd) { + request(s, 'lpd', WORK.lpd); + nextLpd += EXEC.servicerPeriod; + } + // one millisecond of CPU, less the stolen cycles, to the highest priorities first (FIFO among equals) + let cap = DT * (1 - tloss); + let ran = -1; + while (cap > 1e-12 && live.length) { + let best = null; + for (const j of live) if (!best || j.prio > best.prio) best = j; + const use = Math.min(cap, best.left); + best.left -= use; + cap -= use; + if (ran < 0) ran = JOB[best.kind]; + if (best.left <= 1e-12) { + free(best, s, 'done'); + if (best.kind === 'monitor' || best.kind === 'servicer') best.shown = s; // the display it refreshed + } + } + if (s >= si * SAMPLE - 1e-9 && si < N) { + let m = 0; + let v = 0; + sets.forEach((j, k) => j && (m |= 1 << k)); + vacs.forEach((j, k) => j && (v |= 1 << k)); + S.sets[si] = m; + S.vacs[si] = v; + S.run[si] = ran; + S.busy[si] = sets.filter(Boolean).length; + S.duty[si] = tloss + servicerDuty(s) + (monitor ? EXEC.n68 : 0) + (prog === 64 ? EXEC.approachJobs / EXEC.servicerPeriod : 0); + si++; + } + } + // display refreshes: a monitor job's end (Verb 16), a SERVICER pass's end (the program's Verb 06) + const monShown = jobs.filter((j) => j.kind === 'monitor' && j.fate === 'done').map((j) => j.t1); + const servShown = jobs.filter((j) => j.kind === 'servicer' && j.fate === 'done').map((j) => j.t1); + return {S, jobs, alarms, restarts, disp, lampEv, keyEv, monShown, servShown, radar, end}; +} + +const lastLE = (arr, s, key = (x) => x) => { + let lo = 0; + let hi = arr.length - 1; + if (hi < 0 || key(arr[0]) > s) return -1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (key(arr[mid]) <= s) lo = mid; + else hi = mid - 1; + } + return lo; +}; +export const sampleAt = (log, s) => Math.max(0, Math.min(log.S.sets.length - 1, Math.round(s / SAMPLE))); +/** the display state at s, and when its registers were last refreshed */ +export function displayAt(log, s) { + const d = log.disp[Math.max(0, lastLE(log.disp, s, (x) => x.s))]; + const src = d.monitor ? log.monShown : log.servShown; + const k = lastLE(src, s); + const shown = k < 0 ? d.s : Math.max(src[k], d.s); + return {...d, shown}; +} +/** a lamp's state at s */ +export function lampAt(log, s, name) { + let on = 0; + for (const [t, l, v] of log.lampEv) { + if (t > s) break; + if (l === name) on = v; + } + return on; +} +/** the jobs alive at s, in core-set order */ +export function jobsAt(log, s) { + const out = new Array(EXEC.coreSets).fill(null); + for (const j of log.jobs) if (j.t0 <= s && (j.t1 === null || j.t1 > s)) out[j.set] = j; + return out; +} +/** the last restart at or before s */ +export function restartAt(log, s) { + const k = lastLE(log.restarts, s, (x) => x.s); + return k < 0 ? null : log.restarts[k]; +} diff --git a/machines/rope/agc-shared/film.js b/machines/rope/agc-shared/film.js new file mode 100644 index 0000000..15561c5 --- /dev/null +++ b/machines/rope/agc-shared/film.js @@ -0,0 +1,401 @@ +// Rope, the film: the AGC and its DSKY (agc.js) on the drawing sheet (stage.js) under the overlay (console.js). One +// input, the mission time in MCT since PDI (schedule.js), reads the Executive's log (executive.js) and the descent +// (machine.js): the word in the rope, the core sets, the lamps, VERB / NOUN / PROG and R1-R3. An operator opens the +// case, pulls rope module B1 and dives between its cores while one word is read, reads and rewrites one erasable +// word, goes back to the DSKY, watches the radar's stolen cycles fill the core sets until the 1202, then +// fast-forwards through P64 to touchdown. A pure function of t. +import {runSim} from 'teardown-kit/runtime.js'; +import {createConsole} from './console.js'; +import {L} from './agc.js'; +import {loadFonts} from './fonts.js'; +import {AGC, EVENTS, EXEC, GET0, MCT, ROPE_WORDS, bits16, descent, ropePlace} from './machine.js'; +import {SAMPLE, displayAt, jobsAt, lampAt, restartAt, sampleAt, servicerDuty} from './executive.js'; +import {ALARM1, DURATION, ERAS_JOB, ERAS_MCT, LOG, NOVAC, ROPE_MCT, SCRIPT, T as TB, actsAt, camAtName, missionAt, rateAt} from './schedule.js'; +import {createStage} from './stage.js'; +import {nf, T as tr} from './i18n.js'; + +export {DURATION, SCRIPT}; +export const DEFAULTS = {duration: DURATION, fps: 60, theme: 'light', hud: {enabled: false, title: 'rope', subtitle: ''}, director: {enabled: false}, watermark: ''}; +const D2R = Math.PI / 180; +export const smoother = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * u * (u * (u * 6 - 15) + 10); +}; +export const ss = (a, b, t) => smoother((t - a) / (b - a)); +const lerp = (a, b, k) => a + (b - a) * k; +const lerpV = (a, b, k) => ({x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), z: lerp(a.z, b.z, k)}); +const logLerp = (a, b, k) => Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * k); + +// ------------------------------------------------------------ views: straight, slow, along the axes. u: seconds in +// the view, A: the machine's anchors (world space) for the frame +export const VIEW_FN = { + hero: (u) => ({tx: 0.05, ty: 0.07, tz: 0.0, az: 34 + 1.4 * Math.sin(u * 0.41), el: 25, d: 3.15 - 0.06 * Math.sin(u * 0.41), sx: 0, sy: 30}), + open: (u) => ({tx: 0.02, ty: 0.05, tz: -0.16, az: 30 + 0.4 * Math.min(u, 6), el: 38, d: 3.15, sx: 0, sy: 20}), + rope: (u, A) => { + const k = smoother((u - 1.2) / 3.0); + const p = lerpV(A.rope, A.core, smoother((u - 1.6) / 2.6)); + return {tx: p.x, ty: p.y, tz: p.z, az: lerp(28, 48, k), el: lerp(40, 26, k), d: logLerp(1.05, 0.066, k), sx: 0, sy: lerp(20, 0, k)}; + }, + eras: (u, A) => { + const k = smoother((u - 0.6) / 2.4); + const p = lerpV(A.eras, A.erasCore, k); + return {tx: p.x, ty: p.y, tz: p.z, az: lerp(16, 6, k), el: lerp(30, 12, k), d: logLerp(1.1, 0.075, k), sx: 0, sy: lerp(20, 0, k)}; + }, + dsky: (u, A) => ({tx: A.dsky.x, ty: A.dsky.y - 0.052, tz: A.dsky.z, az: 26 - 0.4 * Math.min(u, 6), el: 7, d: 1.22, sx: 0, sy: 6}), + exec: (u) => ({tx: 0.06, ty: 0.05, tz: -0.12, az: 22 + 0.4 * Math.min(u, 8), el: 31, d: 3.35, sx: 0, sy: 16}), + // live only + logic: (u, A) => { + const k = smoother((u - 0.4) / 2.2); + return {tx: lerp(-0.1, A.gate.x - 0.03, k), ty: lerp(0.06, A.gate.y - 0.03, k), tz: lerp(0, A.gate.z - 0.04, k), az: lerp(28, 14, k), el: lerp(36, 20, k), d: logLerp(2.6, 0.62, k), sx: 0, sy: 10}; + }, + graze: (u) => ({tx: L.x0 + 0.02, ty: 0.07, tz: 0.0, az: 78 - 0.6 * Math.min(u, 8), el: 7, d: 1.45, sx: 0, sy: 6}), +}; +export function blend(a, b, k) { + const o = {}; + for (const key in b) o[key] = key === 'az' ? a.az + ((((b.az - a.az + 540) % 360) - 180) * k) : key === 'd' ? logLerp(a.d, b.d, k) : a[key] + (b[key] - a[key]) * k; + // between two close views the camera backs out on the way, so it never passes through the trays + const lift = Math.max(0, Math.log(0.9 / Math.max(a.d, b.d))); + o.d *= Math.exp(lift * Math.sin(Math.PI * k)); + return o; +} +const VIEWS = SCRIPT.filter((e) => e.cam).map((e) => [e.t, e.cam]); +export const camAt = (t, A) => { + let i = 0; + while (i < VIEWS.length - 1 && VIEWS[i + 1][0] <= t) i++; + const view = (k) => VIEW_FN[VIEWS[k][1]](t - VIEWS[k][0], A); + const cur = view(i); + if (i === 0) return cur; + const k = ss(VIEWS[i][0], VIEWS[i][0] + 2.0, t); + return k >= 1 ? cur : blend(view(i - 1), cur, k); +}; + +// ------------------------------------------------------------ the operator's pointer +const REST = [[820, 520], [700, 470], [900, 560], [760, 430]]; +export function makeCursor(ui, script = SCRIPT) { + const EV = script.filter((e) => e.t > 0 && e.btn).map((e) => ({t: e.t, spot: ui.spots[e.btn]})); + const WP = [[0, 980, 640]]; + EV.forEach((e, i) => { + WP.push([e.t - 0.16, ...e.spot]); + WP.push([e.t + 0.24, ...e.spot]); + WP.push([e.t + 1.6, ...REST[i % REST.length]]); + }); + WP.sort((a, b) => a[0] - b[0]); + return (t) => { + let i = 0; + while (i < WP.length - 2 && t > WP[i + 1][0]) i++; + const [t0, x0, y0] = WP[i]; + const [t1, x1, y1] = WP[i + 1]; + const k = ss(t0, t1, t); + const len = Math.hypot(x1 - x0, y1 - y0); + const bow = Math.sin(Math.PI * k) * Math.min(24, len * 0.07); + let press = false; + let ring = 0; + let rx = 0; + let ry = 0; + for (const e of EV) { + if (t >= e.t - 0.07 && t < e.t + 0.09) press = true; + if (t >= e.t && t < e.t + 0.55) { + ring = 1 - (t - e.t) / 0.55; + [rx, ry] = e.spot; + } + } + return {x: x0 + (x1 - x0) * k + (len ? (-(y1 - y0) / len) * bow : 0), y: y0 + (y1 - y0) * k + (len ? ((x1 - x0) / len) * bow : 0), press, ring, ringX: rx, ringY: ry}; + }; +} + +// ------------------------------------------------------------ the machine's state at mission time s +const sgn = (v, n = 5) => (v < 0 ? '-' : '+') + String(Math.min(10 ** n - 1, Math.round(Math.abs(v)))).padStart(n, '0'); +const mmss = (sec) => { + const t = Math.max(0, Math.round(sec)); + return `+${String(Math.min(99, Math.floor(t / 60))).padStart(2, '0')} ${String(t % 60).padStart(2, '0')}`; +}; +const hash = (n) => { + const x = Math.sin(n * 0.61803 + 3.1) * 43758.5453; + return x - Math.floor(x); +}; +/** registers for a noun from the descent at the display's last refresh */ +export function registers(noun, ds, fail, s) { + if (noun === '68') return [sgn(ds.slantNm * 10), mmss(ds.tgo), sgn(ds.dH)]; + if (noun === '63') return [sgn(ds.v * 10), sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '64') return [`+${String(Math.max(0, Math.round(EVENTS.p66 - ds.s))).padStart(2, '0').slice(-2)} ${String(Math.round(ds.lpd)).padStart(2, '0')}`, sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '60') return [sgn(ds.vh * 10), sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '09') return fail.map((c) => (c ? ' ' + c.toString().padStart(5, '0') : ' 00000')); + if (noun === '36') { + const g = GET0 + s; + return [sgn(Math.floor(g / 3600)), sgn(Math.floor((g % 3600) / 60)), sgn(Math.floor((g % 60) * 100))]; + } + return [' ', ' ', ' ']; +} +export function machineState(log, s, {liveKeys} = {}) { + const d = displayAt(log, s); + const ds = descent(d.shown); + const reg = registers(d.noun, ds, d.fail, d.shown); + const verb = d.mode === 'verb' ? d.vb.padEnd(2, ' ') : d.verb; + const noun = d.mode === 'noun' ? d.nb.padEnd(2, ' ') : d.mode === 'verb' ? d.noun : d.noun; + const i = sampleAt(log, s); + const run = log.S.run[i]; + const lamps = { + PROG: lampAt(log, s, 'PROG'), + 'OPR ERR': lampAt(log, s, 'OPR ERR'), + ALT: s < EVENTS.alt || (s > EVENTS.lrDrop && s < EVENTS.lrBack) ? 1 : 0, + VEL: s < EVENTS.vel || (s > EVENTS.lrDrop && s < EVENTS.lrBack) ? 1 : 0, + }; + // the jobs, and which one has the processor + const sets = jobsAt(log, s); + let runJ = null; + for (const j of sets) if (j && (!runJ || j.prio > runJ.prio || (j.prio === runJ.prio && j.t0 < runJ.t0))) runJ = j; + const setsOut = sets.map((j) => (j ? {kind: j.kind, run: j === runJ && run >= 0, age: `${nf(Math.max(0, s - j.t0), 1)} s`} : null)); + const vacs = []; + for (let k = 0; k < EXEC.vacAreas; k++) vacs.push(!!(log.S.vacs[i] & (1 << k))); + const busy = sets.filter(Boolean).length; + const tl = log.radar ? EXEC.tloss : 0; + const prog = s < EVENTS.p64 ? 63 : s < EVENTS.p66 ? 64 : 66; + const serv = servicerDuty(s); + const lrPart = prog === 63 ? Math.max(0, serv - EXEC.braking) : 0; + const mon = d.monitor ? EXEC.n68 : 0; + const lpd = prog === 64 ? EXEC.approachJobs / EXEC.servicerPeriod : 0; + const parts = [ + {k: 'serv', v: serv - lrPart}, + ...(lrPart > 0.001 ? [{k: 'lr', v: lrPart}] : []), + ...(mon ? [{k: 'n68', v: mon}] : []), + ...(lpd ? [{k: 'lpd', v: lpd}] : []), + ...(tl ? [{k: 'rr', v: tl}] : []), + ]; + const total = parts.reduce((a, p) => a + p.v, 0); + // one millisecond of MCTs, ending now + const n0 = Math.floor(s / MCT); + const strip = []; + const idleP = Math.max(0, 1 - total); + const novac0 = Math.round(NOVAC.req.t0 / MCT); + for (let q = 0; q < 85; q++) { + const n = n0 - 84 + q; + const inNovac = n >= novac0 && n < novac0 + NOVAC.mct; + const h = hash(n); + if (!inNovac && h < tl) strip.push('s'); + else if (inNovac || (run >= 0 && h > tl + idleP)) strip.push('j'); + else strip.push(run < 0 ? 'i' : h < tl + idleP ? 'i' : 'j'); + } + // the alarm, while PROG is lit and a few seconds after + const r = restartAt(log, s); + let alarm = null; + if (r) { + const age = s - r.s; + const k = lamps.PROG ? 1 : 1 - Math.min(1, Math.max(0, (age - 6) / 1.5)); + if (k > 0) { + const counts = {}; + for (const x of r.dropped) counts[x.kind] = (counts[x.kind] || 0) + 1; + const kl = {servicer: tr('job:servicer'), monitor: tr('job:monitor'), lpd: tr('job:lpd'), charin: tr('job:charin')}; + alarm = {code: r.code, k: Math.min(1, age / 0.15) * k, kept: r.kept[0]?.kind, dropped: Object.entries(counts).map(([kk, c]) => `${c} × ${kl[kk]}`).join(', ') || '—'}; + } + } + return { + dsky: {prog: String(prog === 66 && s > EVENTS.contact + 3 ? 68 : prog), verb, noun, reg, compActy: run >= 0 ? 1 : 0, lamps}, + d, + ds, + sets: setsOut, + vacs, + busy, + duty: {parts, total}, + strip, + alarm, + }; +} +export const dskyText = (d, ds) => { + const n = d.mode ? '' : d.noun; + if (n === '68') return tr('dsky:68', nf(ds.slantNm, 1), mmss(ds.tgo).slice(1).replace(' ', ':'), nf(Math.round(ds.dH))); + if (n === '63') return tr('dsky:63', nf(ds.v, 1), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '64') return tr('dsky:64', Math.round(ds.lpd), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '60') return tr('dsky:60', nf(ds.vh, 1), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '09') return tr('dsky:09'); + if (n === '36') return tr('dsky:36'); + return tr('dsky:keying'); +}; +export const rateText = (r) => { + if (r < 1e-3) return tr('rateSlow', nf(r * 1e6, 1), nf(1 / r)); + if (r < 0.9) return tr('rateSlowS', nf(r, 2)); + return tr('rateFast', nf(r, r < 10 ? 1 : 0)); +}; + +// ------------------------------------------------------------ the subsystem panels +const ROPE_W = ROPE_WORDS.findIndex((w) => w.op === 'CAF NO.CORES'); +const SEL = ROPE_WORDS[ROPE_W]; +export const ROPE_PLACE = ropePlace(SEL.bank, SEL.addr); +export const prioWord = (kind) => (kind ? ({servicer: 0o20, monitor: 0o10, lpd: 0o10, charin: 0o30}[kind] << 9) | 0o154 : 0o77777); +/** rope read inside its MCT: phase 0..1 → {stage, fill} */ +export function ropeRead(phase) { + if (phase < 0) return {stage: -1, fill: 0}; + if (phase >= 1) return {stage: -1, fill: 16}; + const stage = phase < 0.12 ? 0 : phase < 0.45 ? 1 : phase < 0.72 ? 2 : 3; + const fill = Math.max(0, Math.min(16, Math.floor(((phase - 0.5) / 0.3) * 16 + 1))); + return {stage, fill: phase < 0.5 ? 0 : fill}; +} +export function erasRead(phase) { + if (phase < 0) return {stage: -1, fill: 16, state: 'stored'}; + if (phase >= 1) return {stage: -1, fill: 16, state: 'stored'}; + const stage = phase < 0.18 ? 0 : phase < 0.55 ? 1 : 2; + const fill = phase < 0.2 ? 0 : Math.min(16, Math.floor(((phase - 0.2) / 0.22) * 16 + 1)); + return {stage, fill, state: 'read'}; +} +export function detailFor(kind, s, ms, extra = {}) { + if (kind === 'rope') { + const ph = extra.ropePhase ?? -1; + const r = ropeRead(ph); + return {kind, w: extra.ropeW ?? ROPE_W, place: extra.place ?? ROPE_PLACE, fill: r.fill, stage: r.stage}; + } + if (kind === 'eras') { + const ph = extra.erasPhase ?? -1; + const r = erasRead(ph); + const job = extra.erasJob; + return {kind, set: extra.erasSet ?? 0, job, bits: bits16(prioWord(job)), fill: r.fill, stage: r.stage, state: r.state}; + } + if (kind === 'clock') { + const f = (s / MCT) % 1; + return {kind, pulse: Math.floor(f * 12), phase: f, mct: Math.floor(s / MCT)}; + } + if (kind === 'nor') { + const f = (s / MCT) % 1; + const p = Math.floor(f * 12); + return {kind, in: extra.norIn ?? [p % 2 ? 1 : 0, p >= 4 && p < 8 ? 1 : 0, p >= 9 ? 1 : 0]}; + } + if (kind === 'iface') return {kind, rr: !!extra.radar, lr: s > EVENTS.alt}; + if (kind === 'dsky') return {kind, text: tr('dskyKeysNote')}; + return {kind: 'exec'}; +} + +// ------------------------------------------------------------ callouts per view, label spots on the 1600 x 900 layout +export function calloutsFor(view, st, vis, extra = {}) { + const A = st.mac.anchors; + const out = []; + const c = (key, at, label, w, hot = false, o = vis) => { + const [name, sub] = tr(key); + out.push({name, sub, at: st.project(at), label, w, o, hot}); + }; + if (view === 'hero') { + c('cAgc', A.trayB, [380, 150], 230); + c('cDsky', A.dsky, [1230, 150], -200); + } else if (view === 'open' || view === 'exec') { + c('cTrayA', A.trayA, [380, 600], 220); + c('cTrayB', A.xrib, [1230, 112], -230); + if (view === 'exec') c('cConn', A.conn, [1230, 600], -250, !!extra.rrHot); + } else if (view === 'rope') { + const o2 = vis * (extra.inside ?? 0); + c('cRopeMod', A.ropeEnd, [1230, 112], -250, false, vis * (1 - (extra.inside ?? 0))); + c('cCore', A.core, [380, 112], 250, true, o2); + c('cSense', A.coreNext, [1230, 112], -250, false, o2); + c('cInhibit', A.inhib, [380, 640], 250, false, o2); + } else if (view === 'eras') { + const o2 = vis * (extra.inside ?? 0); + c('cErasMod', A.erasMod, [380, 112], 260, false, vis * (1 - (extra.inside ?? 0))); + c('cErasCore', A.erasCore, [380, 112], 260, true, o2); + c('cErasSense', A.erasSense, [1230, 640], -250, false, o2); + } else if (view === 'dsky') { + c('cEl', A.dskyEl, [1230, 112], -240, true); + c('cKeys', A.dskyKeys, [380, 640], 220); + } else if (view === 'logic') { + c('cLogic', A.logic, [380, 112], 240); + c('cGate', A.gate, [1230, 112], -250, true); + } else if (view === 'graze') { + c('cTrayA', A.trayA, [380, 112], 220); + } + return out; +} + +// ------------------------------------------------------------ captions of the film, by beat +const CAPS = [ + [0, 'cap:hero'], + [3.0, 'cap:open'], + [6.8, 'cap:rope'], + [11.0, 'cap:rope2'], + [14.6, 'cap:eras'], + [20.2, 'cap:dsky'], + [24.0, 'cap:exec'], + [TB.alarm, 'cap:alarm'], + [TB.alarm + 2.4, 'cap:restart'], + [TB.ff, 'cap:ff'], + [35.2, 'cap:p64'], + [TB.contact - 0.4, 'cap:contact'], +]; +export const captionAt = (t) => tr(CAPS.filter((c) => c[0] <= t).pop()[1]); + +// ------------------------------------------------------------ the film +export function makeFilm() { + return async ({canvas, capture}) => { + await loadFonts(); + const st = createStage({canvas}); + const resize = () => st.resize(1600, 900, capture ? devicePixelRatio || 1 : Math.min(devicePixelRatio || 1, 2)); + resize(); + const ui = createConsole(document.getElementById('app')); + const cursorAt = makeCursor(ui); + if (window.__SIM_CONFIG?.clean) ui.root.style.display = 'none'; + window.ROPE = {st, LOG}; + const sR = ROPE_MCT * MCT; + const sE = ERAS_MCT * MCT; + return { + resize, + render(t) { + const s = missionAt(t); + const view = camAtName(t); + const t0 = VIEWS.filter(([a]) => a <= t).pop()[0]; + const since = t - t0; + const ms = machineState(LOG, s); + const acts = actsAt(t); + const ropePhase = (s - sR) / MCT; + const erasPhase = (s - sE) / MCT; + const press = {}; + for (const [ks, k] of LOG.keyEv) { + const dt = s - ks; + if (dt >= 0 && dt < 0.25) press[k] = Math.max(press[k] || 0, 1 - dt / 0.25); + } + const fx = { + ...acts, + read: {phase: ropePhase >= 0 && ropePhase < 1 ? ropePhase : -1}, + erasRead: {phase: erasPhase >= 0 && erasPhase < 1 ? erasPhase : -1, bit: bits16(prioWord(ERAS_JOB?.kind))[1]}, + rr: view === 'exec' || view === 'hero' ? 0.25 + 0.25 * Math.sin(t * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press}, + }; + st.mac.update(fx); + const c = camAt(t, st.mac.anchors); + st.frame(fx, {...c, az: c.az * D2R, el: c.el * D2R}); + const det = + view === 'rope' + ? detailFor('rope', s, ms, {ropePhase}) + : view === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: ERAS_JOB?.kind, erasSet: 0}) + : view === 'dsky' + ? detailFor('dsky', s, ms) + : view === 'exec' + ? detailFor('iface', s, ms, {radar: true}) + : detailFor('clock', s, ms); + const inside = view === 'rope' ? ss(3.0, 4.2, since) : view === 'eras' ? ss(2.0, 3.0, since) : 0; + ui.update({ + get: GET0 + s, + mct: s / MCT, + rateText: rateText(rateAt(t)), + dsky: ms.dsky, + dskyText: dskyText(ms.d, ms.ds), + sets: ms.sets, + vacs: ms.vacs, + busy: ms.busy, + duty: ms.duty, + strip: ms.strip, + stripAt: 84, + alarm: ms.alarm, + execNote: tr('execWait'), + detail: det, + caption: captionAt(t), + sub: {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[view] || '', + cut: acts.open > 0.5 ? 'tray' : '', + view: '', + callouts: calloutsFor(view, st, ss(1.2, 1.9, since) * (t > DURATION - 0.3 ? 0 : 1), {inside, rrHot: true}), + cursor: cursorAt(t), + }); + return {}; + }, + }; + }; +} +void AGC; +void SAMPLE; +void ALARM1; + +export const run = () => runSim(DEFAULTS, makeFilm()); diff --git a/machines/rope/agc-shared/fonts.js b/machines/rope/agc-shared/fonts.js new file mode 100644 index 0000000..b5d545d --- /dev/null +++ b/machines/rope/agc-shared/fonts.js @@ -0,0 +1,26 @@ +// Barlow Condensed (SIL Open Font License, @fontsource/barlow-condensed), the console's technical-drawing capitals: +// the latin subset in three weights, inlined so the page opens offline. Both builds (the site's and the teardown +// skill's) load .woff2 as base64. +import w500 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-500-normal.woff2'; +import w600 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-600-normal.woff2'; +import w700 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-700-normal.woff2'; + +const buf = (b64) => { + const s = atob(b64); + const u = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); + return u.buffer; +}; +export const FAMILY = "'Barlow Condensed', 'Arial Narrow', sans-serif"; +/** registers the three weights; resolves when they are ready to draw with */ +export async function loadFonts() { + const faces = [ + [w500, '500'], + [w600, '600'], + [w700, '700'], + ].map(([b, weight]) => new FontFace('Barlow Condensed', buf(b), {weight, style: 'normal'})); + for (const f of faces) { + document.fonts.add(f); + await f.load().catch(() => {}); + } +} diff --git a/machines/rope/agc-shared/i18n.js b/machines/rope/agc-shared/i18n.js new file mode 100644 index 0000000..dd49315 --- /dev/null +++ b/machines/rope/agc-shared/i18n.js @@ -0,0 +1,223 @@ +// Every word on screen, in English (the default), French and Spanish. The language itself, the number format and the +// strings of the page's header and footer are shared by the collection (../../../shared/i18n.js). +import {LANG, LANGS, makeT, nf} from '../../../shared/i18n.js'; + +export {LANG, LANGS, nf}; + +const S = { + // ------------------------------------------------------------ the clock and the DSKY + clockTitle: {en: 'MISSION TIME · GET', fr: 'TEMPS DE MISSION · GET', es: 'TIEMPO DE MISIÓN · GET'}, + mctSince: {en: 'MEMORY CYCLES (MCT) SINCE PDI', fr: 'CYCLES MÉMOIRE (MCT) DEPUIS LA PDI', es: 'CICLOS DE MEMORIA (MCT) DESDE LA PDI'}, + rateSlow: {en: (us, x) => `1 S ON SCREEN = ${us} µS · SLOWED ×${x}`, fr: (us, x) => `1 S À L’ÉCRAN = ${us} µS · RALENTI ×${x}`, es: (us, x) => `1 S EN PANTALLA = ${us} µS · RALENTIZADO ×${x}`}, + rateSlowS: {en: (r) => `1 S ON SCREEN = ${r} S OF MISSION`, fr: (r) => `1 S À L’ÉCRAN = ${r} S DE MISSION`, es: (r) => `1 S EN PANTALLA = ${r} S DE MISIÓN`}, + rateFast: {en: (r) => `MISSION RUNS ×${r}`, fr: (r) => `LA MISSION DÉFILE ×${r}`, es: (r) => `LA MISIÓN CORRE ×${r}`}, + dskyTitle: {en: 'DSKY · DISPLAY AND KEYBOARD', fr: 'DSKY · AFFICHEUR ET CLAVIER', es: 'DSKY · PANTALLA Y TECLADO'}, + 'dsky:68': { + en: (r, tg, dh) => `V16 N68 · R1 slant range ${r} nmi · R2 time to high gate ${tg} · R3 Δh radar − computed ${dh} ft`, + fr: (r, tg, dh) => `V16 N68 · R1 distance oblique ${r} nmi · R2 temps jusqu’au high gate ${tg} · R3 Δh radar − calculée ${dh} ft`, + es: (r, tg, dh) => `V16 N68 · R1 distancia oblicua ${r} nmi · R2 tiempo hasta el high gate ${tg} · R3 Δh radar − calculada ${dh} ft`, + }, + 'dsky:63': { + en: (v, hd, h) => `V06 N63 · R1 speed ${v} ft/s · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (v, hd, h) => `V06 N63 · R1 vitesse ${v} ft/s · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (v, hd, h) => `V06 N63 · R1 velocidad ${v} ft/s · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:64': { + en: (a, hd, h) => `V06 N64 · R1 time left to redesignate, LPD angle ${a}° · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (a, hd, h) => `V06 N64 · R1 temps pour redésigner, angle LPD ${a}° · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (a, hd, h) => `V06 N64 · R1 tiempo para redesignar, ángulo LPD ${a}° · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:60': { + en: (v, hd, h) => `V06 N60 · R1 forward speed ${v} ft/s · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (v, hd, h) => `V06 N60 · R1 vitesse horizontale ${v} ft/s · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (v, hd, h) => `V06 N60 · R1 velocidad horizontal ${v} ft/s · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:09': {en: 'V05 N09 · the alarm codes: R1 the first, R2 the second, R3 the last', fr: 'V05 N09 · les codes d’alarme : R1 le premier, R2 le deuxième, R3 le dernier', es: 'V05 N09 · los códigos de alarma: R1 el primero, R2 el segundo, R3 el último'}, + 'dsky:36': {en: 'V16 N36 · the computer’s clock: hours, minutes, seconds', fr: 'V16 N36 · l’horloge du calculateur : heures, minutes, secondes', es: 'V16 N36 · el reloj del ordenador: horas, minutos, segundos'}, + 'dsky:keying': {en: 'Keying in a verb and a noun…', fr: 'Saisie d’un verbe et d’un nom…', es: 'Tecleando un verbo y un nombre…'}, + // ------------------------------------------------------------ the Executive + execTitle: {en: 'EXECUTIVE · WAITLIST', fr: 'EXECUTIVE · WAITLIST', es: 'EXECUTIVE · WAITLIST'}, + dutyTitle: {en: 'DUTY ASKED OF THE MACHINE', fr: 'CHARGE DEMANDÉE À LA MACHINE', es: 'CARGA PEDIDA A LA MÁQUINA'}, + 'duty:serv': {en: 'GUIDANCE', fr: 'GUIDAGE', es: 'GUIADO'}, + 'duty:lr': {en: 'RADAR DATA', fr: 'DONNÉES RADAR', es: 'DATOS RADAR'}, + 'duty:n68': {en: 'V16 N68', fr: 'V16 N68', es: 'V16 N68'}, + 'duty:lpd': {en: 'LPD', fr: 'LPD', es: 'LPD'}, + 'duty:rr': {en: 'STOLEN', fr: 'VOLÉS', es: 'ROBADOS'}, + setsTitle: {en: 'CORE SETS · ONE PER JOB', fr: 'CORE SETS · UN PAR TÂCHE', es: 'CORE SETS · UNO POR TAREA'}, + setFree: {en: 'FREE', fr: 'LIBRE', es: 'LIBRE'}, + vacTitle: {en: 'VAC AREAS · INTERPRETED JOBS', fr: 'ZONES VAC · TÂCHES INTERPRÉTÉES', es: 'ZONAS VAC · TAREAS INTERPRETADAS'}, + busyOf: {en: (b, n) => `${b} / ${n} BUSY`, fr: (b, n) => `${b} / ${n} PRIS`, es: (b, n) => `${b} / ${n} OCUPADOS`}, + stripTitle: {en: 'THE LAST MILLISECOND · 85 MCT', fr: 'LA DERNIÈRE MILLISECONDE · 85 MCT', es: 'EL ÚLTIMO MILISEGUNDO · 85 MCT'}, + stripJob: {en: 'JOBS', fr: 'TÂCHES', es: 'TAREAS'}, + stripSteal: {en: 'STOLEN (RADAR)', fr: 'VOLÉS (RADAR)', es: 'ROBADOS (RADAR)'}, + stripIdle: {en: 'IDLE', fr: 'LIBRES', es: 'LIBRES'}, + 'job:servicer': {en: 'SERVICER', fr: 'SERVICER', es: 'SERVICER'}, + 'job:monitor': {en: 'MONITOR V16 N68', fr: 'MONITEUR V16 N68', es: 'MONITOR V16 N68'}, + 'job:lpd': {en: 'LPD DISPLAY', fr: 'AFFICHAGE LPD', es: 'PANTALLA LPD'}, + 'job:charin': {en: 'KEYBOARD', fr: 'CLAVIER', es: 'TECLADO'}, + alarmHead: {en: 'PROGRAM ALARM', fr: 'ALARME PROGRAMME', es: 'ALARMA DE PROGRAMA'}, + 'alarm:1202': {en: 'EXECUTIVE OVERFLOW · NO CORE SETS', fr: 'DÉBORDEMENT DE L’EXECUTIVE · PLUS DE CORE SET', es: 'DESBORDAMIENTO DEL EXECUTIVE · SIN CORE SETS'}, + 'alarm:1201': {en: 'EXECUTIVE OVERFLOW · NO VAC AREAS', fr: 'DÉBORDEMENT DE L’EXECUTIVE · PLUS DE ZONE VAC', es: 'DESBORDAMIENTO DEL EXECUTIVE · SIN ZONAS VAC'}, + restartKept: {en: (k) => `Restart. Kept: ${k}, the newest pass, from its restart point.`, fr: (k) => `Redémarrage. Gardé : ${k}, le passage le plus récent, depuis son point de reprise.`, es: (k) => `Reinicio. Se conserva: ${k}, la pasada más reciente, desde su punto de reanudación.`}, + restartDropped: {en: (d) => `Dropped: ${d}. The landing goes on.`, fr: (d) => `Abandonné : ${d}. L’atterrissage continue.`, es: (d) => `Descartado: ${d}. El alunizaje continúa.`}, + execWait: { + en: 'The Waitlist starts SERVICER every 2 s; a Verb 16 monitor every second. Each job holds a core set until it ends.', + fr: 'La Waitlist lance SERVICER toutes les 2 s ; un moniteur Verb 16 chaque seconde. Chaque tâche garde son core set jusqu’à la fin.', + es: 'La Waitlist lanza SERVICER cada 2 s; un monitor Verb 16 cada segundo. Cada tarea retiene su core set hasta terminar.', + }, + // ------------------------------------------------------------ the detail panel + 'det:rope': {en: 'FIXED MEMORY · ONE WORD', fr: 'MÉMOIRE FIXE · UN MOT', es: 'MEMORIA FIJA · UNA PALABRA'}, + 'det:eras': {en: 'ERASABLE MEMORY · ONE WORD', fr: 'MÉMOIRE EFFAÇABLE · UN MOT', es: 'MEMORIA BORRABLE · UNA PALABRA'}, + 'det:nor': {en: 'LOGIC · ONE NOR GATE', fr: 'LOGIQUE · UNE PORTE NOR', es: 'LÓGICA · UNA PUERTA NOR'}, + 'det:clock': {en: 'CLOCK AND SEQUENCER', fr: 'HORLOGE ET SÉQUENCEUR', es: 'RELOJ Y SECUENCIADOR'}, + 'det:iface': {en: 'INTERFACES · COUNTERS', fr: 'INTERFACES · COMPTEURS', es: 'INTERFACES · CONTADORES'}, + 'det:dsky': {en: 'DSKY · VERB AND NOUN', fr: 'DSKY · VERBE ET NOM', es: 'DSKY · VERBO Y NOMBRE'}, + 'det:exec': {en: 'EXECUTIVE · THE JOBS', fr: 'EXECUTIVE · LES TÂCHES', es: 'EXECUTIVE · LAS TAREAS'}, + ropeAddr: { + en: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · CORE ${c} · STRAND ${st}`, + fr: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · TORE ${c} · BRIN ${st}`, + es: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · NÚCLEO ${c} · HEBRA ${st}`, + }, + ropeOctal: {en: 'THE WORD, IN OCTAL (LUMINARY 099)', fr: 'LE MOT, EN OCTAL (LUMINARY 099)', es: 'LA PALABRA, EN OCTAL (LUMINARY 099)'}, + ropeParity: {en: (p) => `ODD PARITY BIT ${p}`, fr: (p) => `BIT DE PARITÉ IMPAIRE ${p}`, es: (p) => `BIT DE PARIDAD IMPAR ${p}`}, + ropePhase: {en: 'INSIDE ONE MEMORY CYCLE, 11.7 µS', fr: 'DANS UN CYCLE MÉMOIRE, 11,7 µS', es: 'DENTRO DE UN CICLO DE MEMORIA, 11,7 µS'}, + 'ph:inhibit': {en: 'INHIBIT', fr: 'INHIBITION', es: 'INHIBICIÓN'}, + 'ph:set': {en: 'SET', fr: 'SET', es: 'SET'}, + 'ph:reset': {en: 'RESET', fr: 'RESET', es: 'RESET'}, + 'ph:strobe': {en: 'STROBE', fr: 'LECTURE', es: 'LECTURA'}, + ropeNote: { + en: (w, m, c, l) => `${w} words in ${m} modules of ${c} cores. ${l} wires pass through or around each core: through is a 1, around is a 0.`, + fr: (w, m, c, l) => `${w} mots dans ${m} modules de ${c} tores. ${l} fils traversent ou contournent chaque tore : dedans, un 1 ; autour, un 0.`, + es: (w, m, c, l) => `${w} palabras en ${m} módulos de ${c} núcleos. ${l} hilos pasan por dentro o por fuera de cada núcleo: dentro, un 1; fuera, un 0.`, + }, + erasAddr: {en: (k) => `PRIORITY OF CORE SET ${k} · READ BY CCS PRIORITY`, fr: (k) => `PRIORITÉ DU CORE SET ${k} · LUE PAR CCS PRIORITY`, es: (k) => `PRIORIDAD DEL CORE SET ${k} · LEÍDA POR CCS PRIORITY`}, + erasJob: {en: (j) => `${j} · BUSY`, fr: (j) => `${j} · OCCUPÉ`, es: (j) => `${j} · OCUPADO`}, + erasFree: {en: '−0 · FREE', fr: '−0 · LIBRE', es: '−0 · LIBRE'}, + erasPlane: {en: 'THE PLANE SHOWN HOLDS BIT 14 OF EVERY WORD', fr: 'LE PLAN MONTRÉ PORTE LE BIT 14 DE CHAQUE MOT', es: 'EL PLANO MOSTRADO GUARDA EL BIT 14 DE CADA PALABRA'}, + 'es:read': {en: 'X + Y READ', fr: 'LECTURE X + Y', es: 'LECTURA X + Y'}, + 'es:sense': {en: 'SENSE', fr: 'DÉTECTION', es: 'DETECCIÓN'}, + 'es:rewrite': {en: 'REWRITE', fr: 'RÉÉCRITURE', es: 'REESCRITURA'}, + erasNote: { + en: (w, b) => `${w} words, ${b} cores. Reading switches the core to 0: a 1 shows as a pulse on the sense line. The same cycle writes it back.`, + fr: (w, b) => `${w} mots, ${b} tores. Lire fait basculer le tore à 0 : un 1 se voit comme une impulsion sur le fil de lecture. Le même cycle le réécrit.`, + es: (w, b) => `${w} palabras, ${b} núcleos. Leer pone el núcleo a 0: un 1 aparece como un pulso en el hilo de lectura. El mismo ciclo lo reescribe.`, + }, + norHead: {en: 'RESISTOR-TRANSISTOR LOGIC, 3 INPUTS', fr: 'LOGIQUE RÉSISTANCE-TRANSISTOR, 3 ENTRÉES', es: 'LÓGICA RESISTENCIA-TRANSISTOR, 3 ENTRADAS'}, + norNote: { + en: (n, g) => `About ${n} flat-packs, two NOR gates in each: ${g} gates of one kind make the whole processor.`, + fr: (n, g) => `Environ ${n} boîtiers plats, deux portes NOR dans chacun : ${g} portes d’un seul type font tout le processeur.`, + es: (n, g) => `Unos ${n} encapsulados planos, dos puertas NOR en cada uno: ${g} puertas de un solo tipo hacen todo el procesador.`, + }, + clockHead: {en: (f, m) => `${f} MHZ · 12 PULSES = 1 MCT = ${m} µS`, fr: (f, m) => `${f} MHZ · 12 IMPULSIONS = 1 MCT = ${m} µS`, es: (f, m) => `${f} MHZ · 12 PULSOS = 1 MCT = ${m} µS`}, + clockPulse: {en: 'THE TIMING PULSE NOW', fr: 'L’IMPULSION EN COURS', es: 'EL PULSO ACTUAL'}, + clockNote: { + en: (n, a, us) => `${n} memory cycles a second. An addition takes 2 MCT (${us} µS): ${a} a second; one-MCT instructions run twice as many.`, + fr: (n, a, us) => `${n} cycles mémoire par seconde. Une addition prend 2 MCT (${us} µS) : ${a} par seconde ; les instructions d’un MCT en font deux fois plus.`, + es: (n, a, us) => `${n} ciclos de memoria por segundo. Una suma tarda 2 MCT (${us} µS): ${a} por segundo; las instrucciones de un MCT, el doble.`, + }, + ifHead: {en: 'CYCLES STOLEN PER SECOND', fr: 'CYCLES VOLÉS PAR SECONDE', es: 'CICLOS ROBADOS POR SEGUNDO'}, + 'if:rr': {en: 'RENDEZVOUS RADAR CDU', fr: 'CDU DU RADAR DE RENDEZ-VOUS', es: 'CDU DEL RADAR DE ENCUENTRO'}, + 'if:imu': {en: 'IMU CDU (STEADY ATTITUDE)', fr: 'CDU DE L’IMU (ATTITUDE STABLE)', es: 'CDU DE LA IMU (ACTITUD ESTABLE)'}, + 'if:lr': {en: 'LANDING RADAR', fr: 'RADAR D’ATTERRISSAGE', es: 'RADAR DE ALUNIZAJE'}, + 'if:lrOn': {en: 'DATA GOOD', fr: 'DONNÉES BONNES', es: 'DATOS BUENOS'}, + ifTloss: {en: 'TIME LOST TO THE RADAR', fr: 'TEMPS PERDU AU RADAR', es: 'TIEMPO PERDIDO POR EL RADAR'}, + ifNote: { + en: (p, m, th) => `Two angles × ${p} pulses/s at most, one MCT each: ${th} % in theory, ${m} % measured after the flight. The model uses 13 %.`, + fr: (p, m, th) => `Deux angles × ${p} impulsions/s au plus, un MCT chacune : ${th} % en théorie, ${m} % mesurés après le vol. Le modèle prend 13 %.`, + es: (p, m, th) => `Dos ángulos × ${p} pulsos/s como máximo, un MCT cada uno: ${th} % en teoría, ${m} % medido tras el vuelo. El modelo usa 13 %.`, + }, + dskyHead: {en: 'VERB = WHAT TO DO · NOUN = ON WHAT', fr: 'VERBE = QUOI FAIRE · NOM = SUR QUOI', es: 'VERBO = QUÉ HACER · NOMBRE = SOBRE QUÉ'}, + dskyKeysNote: { + en: 'Click the keys. VERB 1 6 NOUN 6 8 ENTR: the Δh monitor that costs 3 % (it can bring the 1202 back). VERB 0 6 NOUN 6 3 ENTR: speed and altitude, once. VERB 0 5 NOUN 0 9 ENTR: the alarm codes. VERB 1 6 NOUN 3 6 ENTR: the clock. RSET turns the lamps off; KEY REL hands the display back to the program.', + fr: 'Cliquez les touches. VERB 1 6 NOUN 6 8 ENTR : le moniteur de Δh qui coûte 3 % (il peut faire revenir le 1202). VERB 0 6 NOUN 6 3 ENTR : vitesse et altitude, une fois. VERB 0 5 NOUN 0 9 ENTR : les codes d’alarme. VERB 1 6 NOUN 3 6 ENTR : l’horloge. RSET éteint les voyants ; KEY REL rend l’affichage au programme.', + es: 'Haz clic en las teclas. VERB 1 6 NOUN 6 8 ENTR: el monitor de Δh que cuesta un 3 % (puede traer de vuelta el 1202). VERB 0 6 NOUN 6 3 ENTR: velocidad y altitud, una vez. VERB 0 5 NOUN 0 9 ENTR: los códigos de alarma. VERB 1 6 NOUN 3 6 ENTR: el reloj. RSET apaga las luces; KEY REL devuelve la pantalla al programa.', + }, + exHead: {en: 'PRIORITY DECIDES WHO RUNS', fr: 'LA PRIORITÉ DÉCIDE QUI TOURNE', es: 'LA PRIORIDAD DECIDE QUIÉN CORRE'}, + exNote: { + en: (c, v, b, lr, n, rr) => `${c} core sets, ${v} VAC areas. Braking guidance asks ${b} %, the landing radar's data ${lr} % more, the V16 N68 monitor ${n} %; the radar steals ${rr} %. Past 100 %, the lowest-priority jobs never finish and keep their core sets: when a job finds none free, 1202.`, + fr: (c, v, b, lr, n, rr) => `${c} core sets, ${v} zones VAC. Le guidage du freinage demande ${b} %, les données du radar d’atterrissage ${lr} % de plus, le moniteur V16 N68 ${n} % ; le radar vole ${rr} %. Au-delà de 100 %, les tâches les moins prioritaires ne finissent jamais et gardent leur core set : quand une tâche n’en trouve plus, 1202.`, + es: (c, v, b, lr, n, rr) => `${c} core sets, ${v} zonas VAC. El guiado de frenado pide ${b} %, los datos del radar de alunizaje ${lr} % más, el monitor V16 N68 ${n} %; el radar roba ${rr} %. Por encima del 100 %, las tareas de menor prioridad nunca terminan y retienen su core set: cuando una tarea no encuentra ninguno libre, 1202.`, + }, + // ------------------------------------------------------------ buttons + grpSubs: {en: 'SUBSYSTEMS', fr: 'SOUS-SYSTÈMES', es: 'SUBSISTEMAS'}, + 'sub:rope': {en: 'ROPE', fr: 'ROPE', es: 'ROPE'}, + 'sub:eras': {en: 'ERASABLE', fr: 'EFFAÇABLE', es: 'BORRABLE'}, + 'sub:nor': {en: 'NOR LOGIC', fr: 'LOGIQUE NOR', es: 'LÓGICA NOR'}, + 'sub:clock': {en: 'CLOCK', fr: 'HORLOGE', es: 'RELOJ'}, + 'sub:exec': {en: 'EXECUTIVE', fr: 'EXECUTIVE', es: 'EXECUTIVE'}, + 'sub:dsky': {en: 'DSKY', fr: 'DSKY', es: 'DSKY'}, + 'sub:iface': {en: 'INTERFACES', fr: 'INTERFACES', es: 'INTERFACES'}, + grpCuts: {en: 'SECTIONS', fr: 'COUPES', es: 'CORTES'}, + 'cut:case': {en: 'CASE', fr: 'BOÎTIER', es: 'CAJA'}, + 'cut:tray': {en: 'TRAYS', fr: 'PLATEAUX', es: 'BANDEJAS'}, + 'cut:module': {en: 'MODULE', fr: 'MODULE', es: 'MÓDULO'}, + grpViews: {en: 'VIEWS', fr: 'VUES', es: 'VISTAS'}, + 'view:axo': {en: 'AXONOMETRIC', fr: 'AXONOMÉTRIE', es: 'AXONOMETRÍA'}, + 'view:dskyFace': {en: 'DSKY FACE', fr: 'FACE DSKY', es: 'CARA DSKY'}, + 'view:ropeIn': {en: 'INSIDE ROPE', fr: 'DANS LA ROPE', es: 'DENTRO DE LA ROPE'}, + 'view:graze': {en: 'GRAZING', fr: 'RASANTE', es: 'RASANTE'}, + // ------------------------------------------------------------ title block + title: {en: 'ROPE', fr: 'ROPE', es: 'ROPE'}, + titleSub: {en: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY', fr: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY', es: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY'}, + titleMission: {en: 'LM · LUMINARY 099 · APOLLO 11 DESCENT', fr: 'LM · LUMINARY 099 · DESCENTE D’APOLLO 11', es: 'LM · LUMINARY 099 · DESCENSO DEL APOLO 11'}, + titleSources: {en: 'AFTER MIT IL / NASA PUBLIC SOURCES', fr: 'D’APRÈS LES SOURCES PUBLIQUES MIT IL / NASA', es: 'SEGÚN FUENTES PÚBLICAS MIT IL / NASA'}, + titleNot: {en: 'A MODEL · NOT AFFILIATED WITH NASA OR MIT', fr: 'UN MODÈLE · SANS LIEN AVEC LA NASA NI LE MIT', es: 'UN MODELO · SIN RELACIÓN CON LA NASA NI EL MIT'}, + titleSheet: {en: 'SHEET 1 OF 1', fr: 'FEUILLE 1 SUR 1', es: 'HOJA 1 DE 1'}, + titleFigs: {en: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`, fr: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`, es: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`}, + // ------------------------------------------------------------ callouts [name, sub] + cAgc: {en: ['Apollo Guidance Computer', 'BLOCK II · 2 TRAYS · 32 KG · 55 W'], fr: ['Apollo Guidance Computer', 'BLOCK II · 2 PLATEAUX · 32 KG · 55 W'], es: ['Apollo Guidance Computer', 'BLOCK II · 2 BANDEJAS · 32 KG · 55 W']}, + cDsky: {en: ['DSKY', 'DISPLAY AND KEYBOARD'], fr: ['DSKY', 'AFFICHEUR ET CLAVIER'], es: ['DSKY', 'PANTALLA Y TECLADO']}, + cTrayA: {en: ['Tray A · logic', '24 LOGIC + 7 INTERFACE MODULES'], fr: ['Plateau A · logique', '24 MODULES LOGIQUES + 7 D’INTERFACE'], es: ['Bandeja A · lógica', '24 MÓDULOS LÓGICOS + 7 DE INTERFAZ']}, + cTrayB: {en: ['Tray B · memory', 'ROPE, ERASABLE, DRIVERS'], fr: ['Plateau B · mémoire', 'ROPE, EFFAÇABLE, PILOTES'], es: ['Bandeja B · memoria', 'ROPE, BORRABLE, CONTROLADORES']}, + cConn: {en: ['Interface connector', 'RADAR PULSES COME IN HERE'], fr: ['Connecteur d’interface', 'LES IMPULSIONS DU RADAR ARRIVENT ICI'], es: ['Conector de interfaz', 'AQUÍ LLEGAN LOS PULSOS DEL RADAR']}, + cRopeMod: {en: ['Rope module B1', '6,144 WORDS · 512 CORES'], fr: ['Module de rope B1', '6 144 MOTS · 512 TORES'], es: ['Módulo de rope B1', '6144 PALABRAS · 512 NÚCLEOS']}, + cCore: {en: ['The selected core', 'INHIBIT LINES HOLD ALL THE OTHERS'], fr: ['Le tore choisi', 'LES FILS D’INHIBITION BLOQUENT TOUS LES AUTRES'], es: ['El núcleo elegido', 'LOS HILOS DE INHIBICIÓN BLOQUEAN LOS DEMÁS']}, + cSense: {en: ['Sense lines', 'THROUGH = 1 · AROUND = 0'], fr: ['Fils de lecture', 'DEDANS = 1 · AUTOUR = 0'], es: ['Hilos de lectura', 'DENTRO = 1 · FUERA = 0']}, + cInhibit: {en: ['Inhibit lines', 'THE ADDRESS, DECODED'], fr: ['Fils d’inhibition', 'L’ADRESSE, DÉCODÉE'], es: ['Hilos de inhibición', 'LA DIRECCIÓN, DECODIFICADA']}, + cErasMod: {en: ['Erasable memory B12', '2,048 WORDS · FERRITE CORES'], fr: ['Mémoire effaçable B12', '2 048 MOTS · TORES DE FERRITE'], es: ['Memoria borrable B12', '2048 PALABRAS · NÚCLEOS DE FERRITA']}, + cErasCore: {en: ['One core, one bit', 'X AND Y HALF-CURRENTS SWITCH IT'], fr: ['Un tore, un bit', 'LES DEMI-COURANTS X ET Y LE BASCULENT'], es: ['Un núcleo, un bit', 'LAS SEMICORRIENTES X E Y LO CONMUTAN']}, + cErasSense: {en: ['Sense line', 'A 1 FLIPS AND SHOWS'], fr: ['Fil de lecture', 'UN 1 BASCULE ET SE VOIT'], es: ['Hilo de lectura', 'UN 1 CAMBIA Y SE VE']}, + cEl: {en: ['Electroluminescent digits', 'REDRAWN EVERY SECOND BY A JOB'], fr: ['Chiffres électroluminescents', 'REDESSINÉS CHAQUE SECONDE PAR UNE TÂCHE'], es: ['Dígitos electroluminiscentes', 'REDIBUJADOS CADA SEGUNDO POR UNA TAREA']}, + cKeys: {en: ['19 keys', 'VERB · NOUN · ENTR'], fr: ['19 touches', 'VERB · NOUN · ENTR'], es: ['19 teclas', 'VERB · NOUN · ENTR']}, + cLogic: {en: ['Logic module A13', '120 FLAT-PACKS, WELDED'], fr: ['Module logique A13', '120 BOÎTIERS PLATS, SOUDÉS'], es: ['Módulo lógico A13', '120 ENCAPSULADOS PLANOS, SOLDADOS']}, + cGate: {en: ['Dual 3-input NOR', 'THE ONLY GATE IN THE MACHINE'], fr: ['Double NOR à 3 entrées', 'LA SEULE PORTE DE LA MACHINE'], es: ['Doble NOR de 3 entradas', 'LA ÚNICA PUERTA DE LA MÁQUINA']}, + // ------------------------------------------------------------ captions + 'cap:hero': {en: 'Apollo 11’s lunar module, five minutes into powered descent. Aldrin has called up Verb 16 Noun 68.', fr: 'Le module lunaire d’Apollo 11, cinq minutes après le début de la descente. Aldrin a demandé Verb 16 Noun 68.', es: 'El módulo lunar del Apolo 11, cinco minutos después de iniciar el descenso. Aldrin ha pedido Verb 16 Noun 68.'}, + 'cap:open': {en: 'Two magnesium trays: tray A is logic, tray B is memory.', fr: 'Deux plateaux en magnésium : le plateau A, la logique ; le plateau B, la mémoire.', es: 'Dos bandejas de magnesio: la bandeja A, la lógica; la bandeja B, la memoria.'}, + 'cap:rope': {en: 'The program lives in six rope modules, woven by hand. Module B1 comes out.', fr: 'Le programme vit dans six modules de rope, tissés à la main. Le module B1 sort.', es: 'El programa vive en seis módulos de rope, tejidos a mano. Sale el módulo B1.'}, + 'cap:rope2': {en: 'One word, CAF NO.CORES: a wire through the core reads 1, a wire around it reads 0.', fr: 'Un mot, CAF NO.CORES : un fil qui traverse le tore lit 1, un fil qui le contourne lit 0.', es: 'Una palabra, CAF NO.CORES: un hilo que atraviesa el núcleo lee 1, uno que lo rodea lee 0.'}, + 'cap:eras': {en: 'Erasable memory: reading a core erases it, so the same cycle writes it back.', fr: 'Mémoire effaçable : lire un tore l’efface, alors le même cycle le réécrit.', es: 'Memoria borrable: leer un núcleo lo borra, así que el mismo ciclo lo reescribe.'}, + 'cap:dsky': {en: 'Back on the DSKY: every digit comes from the descent the computer is flying.', fr: 'Retour au DSKY : chaque chiffre vient de la descente que le calculateur pilote.', es: 'De vuelta al DSKY: cada dígito sale del descenso que el ordenador pilota.'}, + 'cap:exec': {en: 'The rendezvous radar steals 13 % of the cycles. Low-priority jobs never finish, and keep their core sets.', fr: 'Le radar de rendez-vous vole 13 % des cycles. Les tâches peu prioritaires ne finissent pas et gardent leur core set.', es: 'El radar de encuentro roba el 13 % de los ciclos. Las tareas de baja prioridad no terminan y retienen su core set.'}, + 'cap:alarm': {en: '1202: a job asked for a core set and found all eight taken.', fr: '1202 : une tâche a demandé un core set et les a trouvés tous les huit pris.', es: '1202: una tarea pidió un core set y encontró los ocho ocupados.'}, + 'cap:restart': {en: 'Restart: the newest guidance pass is kept, the rest is dropped. The landing goes on.', fr: 'Redémarrage : le dernier passage du guidage est gardé, le reste abandonné. L’atterrissage continue.', es: 'Reinicio: se conserva la última pasada del guiado, se descarta el resto. El alunizaje continúa.'}, + 'cap:ff': {en: 'Fast forward: a second 1202, then high gate.', fr: 'Accéléré : un second 1202, puis le high gate.', es: 'Avance rápido: un segundo 1202, luego el high gate.'}, + 'cap:p64': {en: 'P64, the approach: more alarms, each one a restart that keeps the landing.', fr: 'P64, l’approche : d’autres alarmes, chacune un redémarrage qui garde l’atterrissage.', es: 'P64, la aproximación: más alarmas, cada una un reinicio que mantiene el alunizaje.'}, + 'cap:contact': {en: 'Contact light, 102:45:40.', fr: 'Contact light, 102:45:40.', es: 'Contact light, 102:45:40.'}, + // ------------------------------------------------------------ the live page + 'cap:live:axo': {en: 'Drag to turn, wheel to zoom. Click a subsystem to open it.', fr: 'Glissez pour tourner, molette pour zoomer. Cliquez un sous-système pour l’ouvrir.', es: 'Arrastra para girar, rueda para el zoom. Haz clic en un subsistema para abrirlo.'}, + 'cap:live:rope': {en: 'NOVAC looks for a free core set; each of its words comes out of the rope, one memory cycle at a time.', fr: 'NOVAC cherche un core set libre ; chacun de ses mots sort de la rope, un cycle mémoire à la fois.', es: 'NOVAC busca un core set libre; cada una de sus palabras sale de la rope, un ciclo de memoria a la vez.'}, + 'cap:live:eras': {en: 'CCS PRIORITY reads a core set’s priority word: a destructive read, then the rewrite.', fr: 'CCS PRIORITY lit le mot de priorité d’un core set : lecture destructive, puis réécriture.', es: 'CCS PRIORITY lee la palabra de prioridad de un core set: lectura destructiva y reescritura.'}, + 'cap:live:nor': {en: 'Click A, B or C: the output is 1 only when all three inputs are 0.', fr: 'Cliquez A, B ou C : la sortie vaut 1 seulement quand les trois entrées sont à 0.', es: 'Haz clic en A, B o C: la salida vale 1 solo cuando las tres entradas son 0.'}, + 'cap:live:clock': {en: 'Twelve timing pulses make one memory cycle; everything in the machine is counted in them.', fr: 'Douze impulsions font un cycle mémoire ; tout, dans la machine, se compte en cycles.', es: 'Doce pulsos forman un ciclo de memoria; todo en la máquina se cuenta en ciclos.'}, + 'cap:live:exec': {en: 'Switch the radar off and the 1202 never comes. Key V16 N68 and it comes back.', fr: 'Coupez le radar et le 1202 ne vient jamais. Tapez V16 N68 et il revient.', es: 'Apaga el radar y el 1202 no llega nunca. Teclea V16 N68 y vuelve.'}, + 'cap:live:dsky': {en: 'The DSKY is the crew’s only way in: a verb, a noun, ENTR.', fr: 'Le DSKY est le seul accès de l’équipage : un verbe, un nom, ENTR.', es: 'El DSKY es la única entrada de la tripulación: un verbo, un nombre, ENTR.'}, + 'cap:live:iface': {en: 'Every radar pulse is a counter increment, and every increment steals one memory cycle.', fr: 'Chaque impulsion du radar est un incrément de compteur, et chaque incrément vole un cycle mémoire.', es: 'Cada pulso del radar es un incremento de contador, y cada incremento roba un ciclo de memoria.'}, + radarOn: {en: 'RADAR STEALS: ON (AS FLOWN)', fr: 'VOL DU RADAR : OUI (COMME EN VOL)', es: 'ROBO DEL RADAR: SÍ (COMO EN VUELO)'}, + radarOff: {en: 'RADAR STEALS: OFF', fr: 'VOL DU RADAR : NON', es: 'ROBO DEL RADAR: NO'}, + speed: {en: 'MISSION SPEED', fr: 'VITESSE DE MISSION', es: 'VELOCIDAD DE MISIÓN'}, + restartMission: {en: '↺ PDI + 5 MIN', fr: '↺ PDI + 5 MIN', es: '↺ PDI + 5 MIN'}, + busy: {en: 'REPLANNING THE EXECUTIVE…', fr: 'REPLANIFICATION DE L’EXECUTIVE…', es: 'REPLANIFICANDO EL EXECUTIVE…'}, + tourOn: {en: 'AUTO TOUR · CLICK TO TAKE OVER', fr: 'VISITE AUTO · CLIQUER POUR PRENDRE LA MAIN', es: 'VISITA AUTOMÁTICA · CLIC PARA TOMAR EL CONTROL'}, + tourOff: {en: '▶ RESUME THE TOUR', fr: '▶ REPRENDRE LA VISITE', es: '▶ REANUDAR LA VISITA'}, + turnPhone: {en: 'Turn your phone sideways for the full console', fr: 'Tournez le téléphone pour la console complète', es: 'Gira el teléfono para ver la consola completa'}, + docTitle: {en: 'Rope · the Apollo Guidance Computer, taken apart', fr: 'Rope · l’ordinateur de bord d’Apollo, démonté', es: 'Rope · el ordenador de guiado del Apolo, desmontado'}, + footCredit: { + en: 'A model of the AGC after public sources (MIT Instrumentation Lab, NASA), not affiliated with NASA or MIT', + fr: 'Un modèle de l’AGC d’après les sources publiques (MIT Instrumentation Lab, NASA), sans lien avec la NASA ni le MIT', + es: 'Un modelo del AGC según fuentes públicas (MIT Instrumentation Lab, NASA), sin relación con la NASA ni el MIT', + }, +}; + +/** the string for key in the page's language; extra arguments fill a templated one */ +export const T = makeT(S); diff --git a/machines/rope/agc-shared/live.js b/machines/rope/agc-shared/live.js new file mode 100644 index 0000000..be292fa --- /dev/null +++ b/machines/rope/agc-shared/live.js @@ -0,0 +1,569 @@ +// Rope as a page you drive: the film's machine and overlay, in real time. Seven subsystems (rope, erasable, NOR logic, +// clock, Executive, DSKY, interfaces), three sections (case, trays, module), four views, an orbit you drag and a zoom +// on the wheel, the mission speed from one memory cycle in four seconds to sixty times real time. The DSKY's 19 keys +// work: a verb and a noun go into the Executive's log, which is planned again from that moment, so Verb 16 Noun 68 +// can bring the 1202 back; switching the radar's cycle steals off makes it go away. Left alone, the page plays the +// film's script as an auto tour with the operator's cursor; a click, drag, wheel or key hands control over, 25 s of +// quiet hands it back. +import * as THREE from 'three'; +import {warmComposer} from 'teardown-kit/warm.js'; +import {CUTS, SUBS, VIEWS, createConsole} from './console.js'; +import {loadFonts} from './fonts.js'; +import {CREW_KEYS, EVENTS, GET0, MCT, ROPE_WORDS, bits16, ropePlace} from './machine.js'; +import {jobsAt, simulate} from './executive.js'; +import {DURATION, LOG, SCRIPT, actsAt, camAtName, missionAt, rateAt, ERAS_JOB, ERAS_MCT, ROPE_MCT} from './schedule.js'; +import {L} from './agc.js'; +import {VIEW_FN, calloutsFor, camAt, captionAt, detailFor, dskyText, machineState, makeCursor, prioWord, rateText, smoother, ss} from './film.js'; +import {createStage} from './stage.js'; +import {createChrome} from '../../../shared/chrome.js'; +import {LANG, T as tr} from './i18n.js'; + +const W = 1600; +const H = 900; +const IDLE = 25; +const D2R = Math.PI / 180; +const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); +const wrap = (d) => ((((d + 180) % 360) + 360) % 360) - 180; +const RATES = [{k: 'mct', r: MCT / 4, label: '1 MCT / 4 S'}, {k: '1', r: 1, label: '×1'}, {k: '10', r: 10, label: '×10'}, {k: '60', r: 60, label: '×60'}]; +const VIEW_OF_SUB = {rope: 'rope', eras: 'eras', nor: 'logic', clock: 'open', exec: 'exec', dsky: 'dsky', iface: 'exec'}; + +const CSS = (top) => ` +html,body{margin:0;background:#efe8d8;overflow:hidden} +#app{position:absolute!important;inset:auto!important;left:0;top:0;width:${W}px;height:${H}px;transform-origin:0 0} +#gl{touch-action:none;cursor:grab} +#gl.drag{cursor:grabbing} +.tb{pointer-events:auto;cursor:pointer} +.tb:hover{filter:brightness(.96)} +.hit{position:absolute;pointer-events:auto;cursor:pointer} +.tourchip{position:absolute;left:800px;top:${92 + top}px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;height:26px;padding:0 13px;border:1px solid rgba(70,58,40,.55);background:rgba(241,235,221,.95);color:#2b2925;font:600 13px 'Barlow Condensed',sans-serif;letter-spacing:1.8px;white-space:nowrap;cursor:pointer;pointer-events:auto} +.tourchip i{display:block;width:7px;height:7px;border-radius:50%;background:#1f8a4c} +.tourchip.on i{animation:blink 1.2s steps(2) infinite} +@keyframes blink{50%{opacity:.15}} +.turn{position:fixed;left:0;right:0;bottom:40px;text-align:center;font:600 14px/1.4 'Barlow Condensed',sans-serif;letter-spacing:1.5px;color:#7a7062;text-transform:uppercase;display:none} +@media (orientation:portrait) and (max-width:820px){.turn{display:block}} +.busy{position:absolute;left:800px;top:440px;transform:translateX(-50%);padding:8px 14px;background:rgba(241,235,221,.96);border:1px solid rgba(70,58,40,.55);font:600 14px 'Barlow Condensed',sans-serif;letter-spacing:1.6px;color:#2b2925;display:none} +#app .cap{top:732px} +`; + +export async function runLive() { + const style = document.createElement('style'); + style.textContent = CSS(0); + document.head.appendChild(style); + document.title = tr('docTitle'); + document.documentElement.lang = LANG; + await loadFonts(); + const app = document.getElementById('app'); + const canvas = document.createElement('canvas'); + canvas.id = 'gl'; + app.appendChild(canvas); + const st = createStage({canvas}); + const ui = createConsole(app); + const tourCursor = makeCursor(ui); + + // ------------------------------------------------------------ quality tiers + const TIERS = [ + {samples: 8, shadow: 4096, pr: 2}, + {samples: 4, shadow: 2048, pr: 1.5}, + {samples: 0, shadow: 1024, pr: 1}, + ]; + const light = matchMedia('(pointer: coarse)').matches || Math.min(innerWidth, innerHeight) < 700; + let tier = light ? 2 : 0; + let scale = 1; + const chrome = createChrome({slug: 'rope', name: 'Rope', credit: tr('footCredit')}); + const fit = () => { + const {top, bottom} = chrome.heights(); + const h = innerHeight - top - bottom; + scale = Math.min(innerWidth / W, h / H); + app.style.transform = `translate(${(innerWidth - W * scale) / 2}px, ${top + (h - H * scale) / 2}px) scale(${scale})`; + st.resize(W, H, clamp((devicePixelRatio || 1) * scale, 0.75, TIERS[tier].pr)); + }; + const applyTier = () => { + const {pr, ...q} = TIERS[tier]; + st.setQuality(q); + fit(); + }; + applyTier(); + addEventListener('resize', fit); + const toStage = (cx, cy) => { + const r = app.getBoundingClientRect(); + return [(cx - r.left) / scale, (cy - r.top) / scale]; + }; + const tourChip = document.createElement('div'); + tourChip.className = 'tourchip'; + ui.root.appendChild(tourChip); + const busy = document.createElement('div'); + busy.className = 'busy'; + busy.textContent = tr('busy'); + ui.root.appendChild(busy); + const note = document.createElement('div'); + note.className = 'turn'; + note.textContent = tr('turnPhone'); + document.body.appendChild(note); + + // live controls: mission speed, the radar, back to PDI + 5 min + const ctl = {}; + const mk = (key, x, w, html) => { + const n = document.createElement('div'); + n.className = 'tb'; + Object.assign(n.style, {left: `${x}px`, top: '788px', width: `${w}px`, height: '26px', fontSize: '13px'}); + n.innerHTML = html; + ui.root.appendChild(n); + ctl[key] = n; + return n; + }; + { + const lab = document.createElement('div'); + lab.className = 'gl'; + Object.assign(lab.style, {left: '372px', top: '770px'}); + lab.textContent = tr('speed'); + ui.root.appendChild(lab); + } + RATES.forEach((r, i) => mk('rate' + i, 372 + i * 92, 86, r.label)); + mk('radar', 750, 250, tr('radarOn')); + mk('reset', 1010, 208, tr('restartMission')); + + // ------------------------------------------------------------ the mission: the log, re-planned on the visitor's keys + let radar = true; + let userKeys = []; + let cutKeys = Infinity; // the crew's own keys stop where the visitor took over the keyboard + let log = LOG; + const replan = () => { + busy.style.display = 'block'; + requestAnimationFrame(() => + setTimeout(() => { + log = simulate({keys: [...CREW_KEYS.filter((k) => k[0] < cutKeys), ...userKeys], radar}); + busy.style.display = 'none'; + }, 0), + ); + }; + let s = 306.4; + let rateI = 1; + let T = 0; + + // ------------------------------------------------------------ state + let paused = false; + let view = 'hero'; + let viewAt = 0; + let sub = ''; + let cut = ''; + const off = {az: 0, el: 0, k: 1}; + const cam = {...VIEW_FN.hero(0)}; + const mech = {open: 0, rope: 0, ropeCover: 0, eras: 0, logic: 0, gate: 0}; + let ring = {t: -9, x: 0, y: 0}; + let hidden = false; + let norIn = [0, 0, 0]; + const press = {}; + const pulse = (x, y) => (ring = {t: T, x, y}); + const pulseBtn = (k) => pulse(...(ui.spots[k] || [800, 450])); + + // ------------------------------------------------------------ the tour + const AP = {on: true, t0: 0, loop: 0}; + let lastInput = 0; + const startTour = () => { + AP.on = true; + AP.t0 = T; + AP.loop = 0; + paused = false; + Object.assign(off, {az: 0, el: 0, k: 1}); + if (log !== LOG || radar !== true) { + radar = true; + userKeys = []; + cutKeys = Infinity; + log = LOG; + } + }; + const takeOver = () => { + lastInput = T; + if (!AP.on) return; + AP.on = false; + const cyc = (T - AP.t0) % DURATION; + s = missionAt(cyc); + const name = camAtName(cyc); + view = name; + viewAt = T - (cyc - SCRIPT.filter((e) => e.cam && e.t <= cyc).pop().t); + sub = {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[name] || ''; + const a = actsAt(cyc); + Object.assign(mech, {open: a.open, rope: a.rope, ropeCover: a.ropeCover, eras: a.eras, logic: 0, gate: 0}); + cut = a.open > 0.5 ? 'tray' : ''; + rateI = rateAt(cyc) < 0.01 ? 0 : 1; + }; + tourChip.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + lastInput = T; + if (AP.on) takeOver(); + else startTour(); + }); + addEventListener('pointerdown', (e) => { + if (e.target !== tourChip) takeOver(); + }); + + // ------------------------------------------------------------ controls + const setView = (v) => { + view = v; + viewAt = T; + Object.assign(off, {az: 0, el: 0, k: 1}); + }; + for (const k of SUBS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + sub = sub === k && k !== 'dsky' ? '' : k; + if (sub) { + if (['rope', 'eras', 'nor', 'clock', 'exec', 'iface'].includes(sub)) cut = sub === 'exec' || sub === 'clock' || sub === 'iface' ? 'tray' : 'module'; + setView(VIEW_OF_SUB[sub]); + rateI = sub === 'rope' || sub === 'eras' ? 0 : rateI === 0 ? 1 : rateI; + } else setView(mech.open > 0.5 ? 'open' : 'hero'); + }); + for (const k of CUTS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + cut = cut === k ? '' : k; + if (cut === 'case') setView('hero'); + else if (cut === 'tray') setView(sub === 'nor' || sub === 'rope' || sub === 'eras' ? 'open' : view === 'hero' ? 'open' : view); + else if (cut === 'module') { + if (!['rope', 'eras', 'nor'].includes(sub)) sub = 'rope'; + setView(VIEW_OF_SUB[sub]); + } else setView('hero'); + }); + for (const k of VIEWS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + if (k === 'axo') setView(mech.open > 0.5 || cut === 'tray' || cut === 'module' ? 'open' : 'hero'); + else if (k === 'dskyFace') { + sub = 'dsky'; + setView('dsky'); + } else if (k === 'ropeIn') { + sub = 'rope'; + cut = 'module'; + rateI = 0; + setView('rope'); + } else if (k === 'graze') { + if (!cut || cut === 'case') cut = 'tray'; + setView('graze'); + } + }); + RATES.forEach((r, i) => + ctl['rate' + i].addEventListener('click', () => { + takeOver(); + rateI = i; + pulse(...centreOf(ctl['rate' + i])); + }), + ); + ctl.radar.addEventListener('click', () => { + takeOver(); + radar = !radar; + pulse(...centreOf(ctl.radar)); + replan(); + }); + ctl.reset.addEventListener('click', () => { + takeOver(); + s = 300; + userKeys = []; + cutKeys = Infinity; + log = LOG; + if (!radar) replan(); + pulse(...centreOf(ctl.reset)); + }); + const centreOf = (n) => [parseFloat(n.style.left) + parseFloat(n.style.width) / 2, parseFloat(n.style.top) + 13]; + // NOR inputs: three hit areas over the schematic's A, B, C + for (let i = 0; i < 3; i++) { + const d = document.createElement('div'); + d.className = 'hit'; + Object.assign(d.style, {left: `${34 + 14 + 60 + i * 60 - 50}px`, top: `${452 + 36 + 124 - 16}px`, width: '36px', height: '32px'}); + d.addEventListener('click', () => { + if (sub !== 'nor') return; + takeOver(); + norIn[i] = norIn[i] ? 0 : 1; + pulse(34 + 14 + 60 + i * 60 - 32, 452 + 36 + 124); + }); + ui.root.appendChild(d); + } + // the DSKY's keys, by raycast on the 3D face + const ray = new THREE.Raycaster(); + const keyMeshes = Object.values(st.mac.dsky.keys); + const keyAt = (cx, cy) => { + const [x, y] = toStage(cx, cy); + ray.setFromCamera(new THREE.Vector2((x / W) * 2 - 1, -(y / H) * 2 + 1), st.camera); + const hit = ray.intersectObjects(keyMeshes, false)[0]; + return hit?.object.userData.key || null; + }; + const pressKey = (k) => { + if (cutKeys === Infinity) cutKeys = s; + userKeys.push([s + 0.001, k]); + press[k] = T; + replan(); + }; + addEventListener('keydown', (e) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const key = e.key.toLowerCase(); + takeOver(); + if (key === ' ') paused = !paused; + else if (key === 'h') hidden = !hidden; + else if (/^[1-7]$/.test(key)) ui.btn[SUBS[+key - 1]].click(); + else return; + e.preventDefault(); + }); + let drag = null; + canvas.addEventListener('pointerdown', (e) => { + const k = keyAt(e.clientX, e.clientY); + if (k) { + takeOver(); + pressKey(k); + return; + } + drag = {x: e.clientX, y: e.clientY}; + canvas.setPointerCapture(e.pointerId); + canvas.classList.add('drag'); + }); + canvas.addEventListener('pointermove', (e) => { + if (!drag) { + canvas.style.cursor = keyAt(e.clientX, e.clientY) ? 'pointer' : ''; + return; + } + off.az += ((e.clientX - drag.x) / scale) * 0.25; + off.el = clamp(off.el + ((e.clientY - drag.y) / scale) * 0.18, -20, 50); + drag = {x: e.clientX, y: e.clientY}; + lastInput = T; + }); + const endDrag = () => { + drag = null; + canvas.classList.remove('drag'); + }; + canvas.addEventListener('pointerup', endDrag); + canvas.addEventListener('pointercancel', endDrag); + canvas.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + takeOver(); + off.k = clamp(off.k * Math.exp(e.deltaY * 0.0012), 0.35, 2.6); + }, + {passive: false}, + ); + + // ------------------------------------------------------------ loops of the live subsystems + const ROPE_LOOP = [0o2625, 0o2626, 0o2627, 0o2630, 0o2631, 0o2632, 0o2633]; + const ropeLoop = (t) => { + const n = Math.floor(t / 3.2); + const addr = ROPE_LOOP[n % ROPE_LOOP.length]; + const w = ROPE_WORDS.findIndex((x) => x.bank === 1 && x.addr === addr); + return {phase: (t / 3.2) % 1, w, core: 4 + (addr - 0o2627), place: ropePlace(1, addr)}; + }; + + // ------------------------------------------------------------ the frame + startTour(); + let last = performance.now(); + const perf = {n: 0, sum: 0, slow: 0, age: 0}; + const ease = (key, want, dt, tau = 0.5) => (mech[key] += (want - mech[key]) * (1 - Math.exp(-dt / tau))); + const frame = (now) => { + const dt = Math.min(0.1, Math.max(0, (now - last) / 1000)); + last = now; + T += dt; + if (tier < TIERS.length - 1) { + perf.age += dt; + if (perf.age > 0.6) { + perf.sum += dt; + perf.n++; + if (perf.sum >= 0.5 && perf.n >= 6) { + perf.slow = perf.sum / perf.n > 0.022 ? perf.slow + 1 : 0; + perf.sum = 0; + perf.n = 0; + if (perf.slow >= (perf.age < 6 ? 1 : 2)) { + tier++; + applyTier(); + Object.assign(perf, {age: 0, slow: 0}); + } + } + } + } + let fx; + let tgt; + let ms; + let det; + let callouts; + let cursor; + let caption; + let rateTxt; + let curView; + let pressNow = {}; + if (AP.on) { + // the film, exactly + const c = T - AP.t0; + const loop = Math.floor(c / DURATION); + const cyc = c - loop * DURATION; + if (loop > AP.loop) AP.loop = loop; + s = missionAt(cyc); + curView = camAtName(cyc); + const t0 = SCRIPT.filter((e) => e.cam && e.t <= cyc).pop().t; + const since = cyc - t0; + ms = machineState(LOG, s); + const acts = actsAt(cyc); + const ropePhase = (s - ROPE_MCT * MCT) / MCT; + const erasPhase = (s - ERAS_MCT * MCT) / MCT; + for (const [ks, k] of LOG.keyEv) { + const d = s - ks; + if (d >= 0 && d < 0.25) pressNow[k] = Math.max(pressNow[k] || 0, 1 - d / 0.25); + } + fx = { + ...acts, + read: {phase: ropePhase >= 0 && ropePhase < 1 ? ropePhase : -1}, + erasRead: {phase: erasPhase >= 0 && erasPhase < 1 ? erasPhase : -1, bit: bits16(prioWord(ERAS_JOB?.kind))[1]}, + rr: curView === 'exec' || curView === 'hero' ? 0.25 + 0.25 * Math.sin(T * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press: pressNow}, + }; + st.mac.update(fx); + tgt = camAt(cyc, st.mac.anchors); + Object.assign(mech, {open: acts.open, rope: acts.rope, ropeCover: acts.ropeCover, eras: acts.eras, logic: 0, gate: 0}); + det = + curView === 'rope' + ? detailFor('rope', s, ms, {ropePhase}) + : curView === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: ERAS_JOB?.kind, erasSet: 0}) + : curView === 'dsky' + ? detailFor('dsky', s, ms) + : curView === 'exec' + ? detailFor('iface', s, ms, {radar: true}) + : detailFor('clock', s, ms); + const inside = curView === 'rope' ? ss(3.0, 4.2, since) : curView === 'eras' ? ss(2.0, 3.0, since) : 0; + callouts = calloutsFor(curView, st, ss(1.2, 1.9, since), {inside, rrHot: true}); + cursor = tourCursor(cyc); + if (AP.loop > 0 && cyc < 0.8) { + const end = tourCursor(DURATION - 0.01); + const k = ss(0, 0.8, cyc); + cursor = {...cursor, x: end.x + (cursor.x - end.x) * k, y: end.y + (cursor.y - end.y) * k}; + } + caption = captionAt(cyc); + rateTxt = rateText(rateAt(cyc)); + } else { + if (T - lastInput > IDLE) startTour(); + const since = T - viewAt; + curView = view; + if (!paused) s += RATES[rateI].r * dt; + if (s > EVENTS.contact + 6) s = 300; + ms = machineState(log, s); + // mechanisms toward the section and the subsystem + const open = cut === 'tray' || cut === 'module' || ['rope', 'eras', 'nor', 'graze'].includes(view) || (sub && sub !== 'dsky') ? 1 : 0; + ease('open', open, dt, 0.55); + const ready = mech.open > 0.97; + ease('rope', ready && (sub === 'rope' || view === 'rope') ? 1 : 0, dt, 0.45); + ease('ropeCover', mech.rope > 0.95 && (sub === 'rope' || view === 'rope') ? 1 : 0, dt, 0.3); + ease('eras', ready && sub === 'eras' ? 1 : 0, dt, 0.45); + ease('logic', ready && sub === 'nor' ? 1 : 0, dt, 0.45); + ease('gate', mech.logic > 0.9 && sub === 'nor' ? 1 : 0, dt, 0.35); + const inRope = mech.ropeCover > 0.97 && view === 'rope'; + const rl = ropeLoop(T); + const eLoopN = Math.floor(T / 4); + const eSet = eLoopN % 8; + const eJob = jobsAt(log, s)[eSet]; + const erasPhase = sub === 'eras' && mech.eras > 0.95 ? (T / 4) % 1 : -1; + for (const [k, t] of Object.entries(press)) { + const d = T - t; + if (d >= 0 && d < 0.25) pressNow[k] = 1 - d / 0.25; + } + for (const [ks, k] of log.keyEv) { + const d = s - ks; + if (d >= 0 && d < 0.25 && RATES[rateI].r <= 1) pressNow[k] = Math.max(pressNow[k] || 0, 1 - d / 0.25); + } + fx = { + ...mech, + ropeDetail: inRope ? 1 : 0, + read: {phase: inRope ? rl.phase : -1, core: rl.core}, + erasRead: {phase: erasPhase, bit: bits16(prioWord(eJob?.kind))[1]}, + cut: cut === 'case' ? 'case' : '', + rr: (sub === 'iface' || sub === 'exec') && radar ? 0.25 + 0.25 * Math.sin(T * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press: pressNow}, + }; + st.mac.update(fx); + const fn = VIEW_FN[curView] || VIEW_FN.hero; + tgt = fn(curView === 'rope' && !inRope && mech.rope < 0.9 ? Math.min(since, 1.0) : since, st.mac.anchors); + tgt.az += off.az; + tgt.el = clamp(tgt.el + off.el, -10, 85); + tgt.d *= off.k; + const kind = sub || 'clock'; + det = + kind === 'rope' + ? detailFor('rope', s, ms, {ropePhase: inRope ? rl.phase : -1, ropeW: rl.w, place: rl.place}) + : kind === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: eJob?.kind, erasSet: eSet}) + : kind === 'nor' + ? detailFor('nor', s, ms, {norIn}) + : kind === 'iface' + ? detailFor('iface', s, ms, {radar}) + : detailFor(kind, s, ms); + const inside = curView === 'rope' ? (inRope ? 1 : 0) : curView === 'eras' ? ss(2.0, 3.0, since) : 0; + callouts = calloutsFor(curView, st, ss(1.2, 1.9, since), {inside, rrHot: radar}); + const rk = Math.max(0, 1 - (T - ring.t) / 0.55); + cursor = {x: -99, y: -99, press: false, ring: rk, ringX: ring.x, ringY: ring.y}; + caption = tr('cap:live:' + (sub || 'axo')); + rateTxt = paused ? '‖' : rateText(RATES[rateI].r); + } + // the camera eases toward its target, logarithmically in distance (metres down to centimetres) + const k = 1 - Math.exp(-dt / (AP.on ? 0.1 : 0.35)); + cam.az += wrap(tgt.az - cam.az) * k; + for (const key of ['tx', 'ty', 'tz', 'el', 'sx', 'sy']) cam[key] += ((tgt[key] ?? 0) - (cam[key] ?? 0)) * k; + cam.d = Math.exp(Math.log(cam.d) + (Math.log(tgt.d) - Math.log(cam.d)) * k); + st.frame(fx, {...cam, az: cam.az * D2R, el: cam.el * D2R}); + + ui.root.style.display = hidden ? 'none' : ''; + ui.update({ + get: GET0 + s, + mct: s / MCT, + rateText: rateTxt, + dsky: ms.dsky, + dskyText: dskyText(ms.d, ms.ds), + sets: ms.sets, + vacs: ms.vacs, + busy: ms.busy, + duty: ms.duty, + strip: ms.strip, + stripAt: 84, + alarm: ms.alarm, + execNote: tr('execWait'), + detail: det, + caption, + sub: AP.on ? {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[curView] || '' : sub, + cut: AP.on ? (mech.open > 0.5 ? 'tray' : '') : cut, + view: AP.on ? '' : {open: 'axo', hero: 'axo', dsky: 'dskyFace', rope: 'ropeIn', graze: 'graze'}[curView] || '', + callouts, + cursor, + }); + RATES.forEach((r, i) => ctl['rate' + i].classList.toggle('on', !AP.on && i === rateI && !paused)); + const rt = radar ? tr('radarOn') : tr('radarOff'); + if (ctl.radar.textContent !== rt) ctl.radar.textContent = rt; + if (tourChip.classList.contains('on') !== AP.on || !tourChip.firstChild) { + tourChip.classList.toggle('on', AP.on); + tourChip.innerHTML = AP.on ? `${tr('tourOn')}` : tr('tourOff'); + } + if (!window.SIM_SHOWN) { + window.SIM_SHOWN = true; + dispatchEvent(new Event('simframe')); + } + requestAnimationFrame(frame); + }; + // the first pose before warming, so every program the frame needs exists + st.mac.update({...mech, ropeDetail: 1, dsky: {lamps: {}, el: machineState(LOG, s).dsky, press: {}}}); + warmComposer(st.renderer, st.composer, st.scene, st.camera).then(() => requestAnimationFrame(frame)); + window.LIVE = { + st, + cam, + startTour, + takeOver, + pressKey, + get tier() { + return tier; + }, + get T() { + return T; + }, + get s() { + return s; + }, + get log() { + return log; + }, + }; + void smoother; + void L; +} diff --git a/machines/rope/agc-shared/machine.js b/machines/rope/agc-shared/machine.js new file mode 100644 index 0000000..3d322af --- /dev/null +++ b/machines/rope/agc-shared/machine.js @@ -0,0 +1,285 @@ +// Rope's one source of figures: the Apollo Guidance Computer Block II (the LM's LGC, Luminary 099, Apollo 11), its +// memories, its clock, its Executive and the descent it flew. No three.js here: the geometry (agc.js), the console, +// the score and check.mjs all read these numbers. Letters refer to REFERENCES.md; [E] marks an estimate. +// +// The one input of the machine is the mission time, counted in memory cycles (MCT) since powered descent initiation +// (PDI, 102:33:05 GET). Everything on screen is a function of it: the word in the rope, the core sets, the lamps, +// VERB / NOUN / PROG and R1-R3 on the DSKY. + +// ------------------------------------------------------------ the computer [W] [S] [H] +export const AGC = { + wordBits: 16, // 15 data bits + 1 odd-parity bit [W] [S] + dataBits: 15, + fixedWords: 36864, // core rope [W] + ropeModules: 6, // B1-B6 [S] + coresPerModule: 512, // 2 layers of 256 [S] + wordsPerCore: 12, // 12 strands of 16 sense lines = 192 lines through or around each core [S] + senseLinesPerCore: 192, + bankWords: 1024, // a fixed bank: 36 banks [V] + erasableWords: 2048, // coincident-current ferrite cores [W] + clockHz: 2.048e6, // crystal [W] + pulsesPerMct: 12, // timing pulses T1-T12, at 1.024 MHz [H] + addMct: 2, // AD: 2 MCT [H] + ics: 2800, // ~2,800 flat-packs, dual 3-input NOR gates [W] + gatesPerIc: 2, + norInputs: 3, + logicModules: 24, // A1-A24, ~120 flat-packs each; A25-A31 interface [S] + massKg: 32, // 70 lb [W] + powerW: 55, // [W] + sizeIn: [24, 12.5, 6.5], // [W] + sizeM: [0.61, 0.32, 0.17], // length, width, height + dskyM: [0.203, 0.203, 0.178], // LM DSKY, 8 x 8 x 7 in [D] +}; +/** one memory cycle: 12 pulses of the 1.024 MHz timing chain (the 2.048 MHz clock halved) */ +export const MCT = AGC.pulsesPerMct / (AGC.clockHz / 2); // 11.71875 µs +export const MCT_PER_S = 1 / MCT; // 85,333 +export const ADD_US = AGC.addMct * MCT * 1e6; // 23.4 µs +export const ADDS_PER_S = 1 / (AGC.addMct * MCT); // 42,667 +export const FIXED_BITS = AGC.fixedWords * AGC.wordBits; // 589,824 +export const ERASABLE_BITS = AGC.erasableWords * AGC.wordBits; // 32,768 +export const WORDS_PER_MODULE = AGC.coresPerModule * AGC.wordsPerCore; // 6,144 + +// ------------------------------------------------------------ the Executive and the radar [E1] [L] +export const EXEC = { + coreSets: 8, // "EIGHT SETS OF 12 REGISTERS EACH" (Luminary 099, ERASABLE_ASSIGNMENTS) [L] [E1] + vacAreas: 5, // [L] [E1] + servicerPeriod: 2, // s, average-G navigation cycle (READACCS) [E1] + // the duty cycle each phase needs, of the whole machine [E1]: margin over 15 % in braking until the landing + // radar locks, ~13 % after (converting its data), 10 % or less with Verb 16 Noun 68 on the DSKY; under 10 % in P64 + braking: 0.85, + lrConvert: 0.015, + n68: 0.03, + approach: [0.835, 0.9], // P64, essential functions only, high gate to P66: the margin falls below 10 % [E1] [E] + manual: 0.8, // P66, lighter [E1] [E] + // the rendezvous radar's CDUs: 2 angles x 6,400 pulses/s at most, one MCT stolen per pulse [E1] + rrPulses: 6400, + rrAngles: 2, + tlossMax: 0.1336, // measured after the flight [E1] + tloss: 0.13, // the value MIT used to reproduce the flight [E1] + // the job mix of this model (which jobs hold a core set and how often they come) is simplified [E] + monitorPeriod: 1, // s, a Verb 16 monitor redisplays every second [P] + approachJobs: 0.06, // P64: the landing-point-designator display job, each 2 s, s of CPU [E] +}; +export const TLOSS_THEORY = EXEC.rrAngles * EXEC.rrPulses * MCT; // 15.0 % + +// ------------------------------------------------------------ the descent of Apollo 11 [J] [R] +export const GET0 = 102 * 3600 + 33 * 60 + 5; // PDI, 102:33:05 GET [R] +export const EVENTS = { + vel: 288, // landing radar velocity data good [E] + alt: 296, // "Altitude light's out", 102:38:11 [J] + p64: 507, // high gate, 102:41:32, 7,400 ft [E1] + p66: 603, // ATT HOLD, P66, 102:43:08 [E1] + lrDrop: 668, // "Altitude (and) velocity lights" 102:44:13 [J] + lrBack: 674, + contact: 755, // "Contact light", 102:45:40 [J] +}; +/** the Apollo 11 alarms as flown, s after PDI, to compare with the model's [R] [E1] */ +export const FLOWN_ALARMS = [ + [317, 1202], // 102:38:22 + [357, 1202], // 102:39:02 + [553, 1201], // 102:42:18 + [578, 1202], // 102:42:43 + [593, 1202], // 102:42:58 +]; +// altitude (ft) and horizontal speed (ft/s) against s after PDI: the crew's call-outs [J]; before high gate, where +// nobody called numbers, a smooth braking profile through the published points (33,500 ft at the first alarm, +// 7,400 ft at high gate) [E] +const ALT_KEYS = [ + [0, 50000], [120, 47200], [240, 40400], [317, 33500], [400, 22500], [460, 14000], [507, 7400], [526, 5000], + [545, 3000], [556, 2000], [598, 750], [602, 700], [606, 600], [611, 540], [621, 400], [628, 350], [641, 300], + [662, 250], [671, 220], [679, 200], [686, 160], [695, 120], [700, 100], [709, 75], [732, 40], [736, 30], [755, 5], [760, 0], +]; +const VH_KEYS = [ + [0, 5560], [120, 4610], [240, 3480], [317, 2700], [400, 1640], [460, 960], [507, 470], [545, 230], [598, 90], + [621, 58], [641, 47], [662, 19], [671, 13], [679, 11], [700, 9], [709, 6], [736, 2], [755, 0.5], [760, 0], +]; +const LPD_KEYS = [[507, 47], [560, 40], [602, 33], [611, 30], [640, 30]]; // LPD angle, degrees [J] [E] + +/** Fritsch-Carlson monotone cubic through keys [[x, y]] */ +export function monotone(keys) { + const n = keys.length; + const x = keys.map((k) => k[0]); + const y = keys.map((k) => k[1]); + const d = []; + const m = new Array(n).fill(0); + for (let i = 0; i < n - 1; i++) d.push((y[i + 1] - y[i]) / (x[i + 1] - x[i])); + m[0] = d[0]; + m[n - 1] = d[n - 2]; + for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (3 * (d[i - 1] + d[i])) / ((2 * d[i] + d[i - 1]) / d[i - 1] + (d[i] + 2 * d[i - 1]) / d[i]); + const f = (t) => { + if (t <= x[0]) return y[0] + m[0] * (t - x[0]); + if (t >= x[n - 1]) return y[n - 1]; + let i = 0; + while (t > x[i + 1]) i++; + const h = x[i + 1] - x[i]; + const s = (t - x[i]) / h; + const s2 = s * s; + const s3 = s2 * s; + return (2 * s3 - 3 * s2 + 1) * y[i] + (s3 - 2 * s2 + s) * h * m[i] + (-2 * s3 + 3 * s2) * y[i + 1] + (s3 - s2) * h * m[i + 1]; + }; + return f; +} +const altF = monotone(ALT_KEYS); +const vhF = monotone(VH_KEYS); +const lpdF = monotone(LPD_KEYS); +const END = 760; +// range still to fly, tabulated once: ∫ vh dt from s to the touchdown point +const RNG_DT = 0.25; +const RNG = (() => { + const n = Math.ceil(END / RNG_DT) + 1; + const r = new Float64Array(n); + for (let i = n - 2; i >= 0; i--) r[i] = r[i + 1] + 0.5 * (vhF(i * RNG_DT) + vhF((i + 1) * RNG_DT)) * RNG_DT; + return r; +})(); +const FT_PER_NM = 6076.12; +const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); + +/** the LM's state s seconds after PDI: altitude (ft), its rate (ft/s), horizontal and total speed, range (ft) */ +export function descent(s) { + const t = clamp(s, 0, END); + const h = Math.max(0, altF(t)); + const hdot = t >= END ? 0 : (altF(Math.min(END, t + 0.5)) - altF(Math.max(0, t - 0.5))) / (Math.min(END, t + 0.5) - Math.max(0, t - 0.5)); + const vh = Math.max(0, vhF(t)); + const i = Math.floor(t / RNG_DT); + const k = t / RNG_DT - i; + const range = i >= RNG.length - 1 ? 0 : RNG[i] * (1 - k) + RNG[i + 1] * k; + // Delta-H: landing-radar altitude minus computed altitude, -2,900 ft when the radar locked [J], then incorporated [E] + const dH = t < EVENTS.alt ? -2900 : -2900 * Math.exp(-(t - EVENTS.alt) / 38); + return {s: t, h, hdot, vh, v: Math.hypot(vh, hdot), range, slantNm: Math.hypot(range, h) / FT_PER_NM, dH, lpd: lpdF(t), tgo: Math.max(0, EVENTS.p64 - t)}; +} +export const DESCENT_END = END; + +// ------------------------------------------------------------ the rope: real words of Luminary 099 [L] +// Assembled words of the Executive's NOVAC (find a core set) and of BAILOUT1, from the Virtual AGC listing of +// Luminary 099 (bank, address, octal word, label, instruction). 15 bits each; the 16th, odd parity, is computed. +export const ROPE_WORDS = [ + [1, 0o2625, 0o34755, 'NOVAC2', 'CAF ZERO'], + [1, 0o2626, 0o54064, '', 'TS LOCCTR'], + [1, 0o2627, 0o32634, '', 'CAF NO.CORES'], + [1, 0o2630, 0o54062, 'NOVAC3', 'TS EXECTEM2'], + [1, 0o2631, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2632, 0o10167, '', 'CCS PRIORITY'], + [1, 0o2633, 0o12674, '', 'TCF NEXTCORE'], + [1, 0o2634, 0o00007, 'NO.CORES', 'DEC 7'], + [1, 0o2635, 0o12674, '', 'TCF NEXTCORE'], + [1, 0o2636, 0o30063, 'CORFOUND', 'CA NEWPRIO'], + [1, 0o2637, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2640, 0o54167, '', 'TS PRIORITY'], + [1, 0o2641, 0o75004, '', 'MASK LOW9'], + [1, 0o2642, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2643, 0o54166, '', 'TS PUSHLOC'], + [1, 0o2674, 0o33033, 'NEXTCORE', 'CAF COREINC'], + [1, 0o2675, 0o26064, '', 'ADS LOCCTR'], + [1, 0o2676, 0o10062, '', 'CCS EXECTEM2'], + [1, 0o2677, 0o12630, '', 'TCF NOVAC3'], + [1, 0o2700, 0o22061, '', 'LXCH EXECTEM1'], + [1, 0o2701, 0o30002, '', 'CA Q'], + [1, 0o2702, 0o05716, '', 'TC BAILOUT1'], + [1, 0o2703, 0o01202, '', 'OCT 1202'], + [2, 0o5716, 0o00004, 'BAILOUT1', 'INHINT'], + [2, 0o5717, 0o53364, '', 'DXCH ALMCADR'], + [2, 0o5720, 0o35742, '', 'CAF ADR40400'], + [2, 0o5721, 0o54061, 'BOTHABRT', 'TS ITEMP1'], + [2, 0o5722, 0o50002, '', 'INDEX Q'], + [2, 0o5723, 0o30000, '', 'CAF 0'], +].map(([bank, addr, word, label, op]) => ({bank, addr, word, label, op, parity: parityBit(word), bits: bits16(word)})); +// the rest of bank 01 around NOVAC2, for the cores either side of the ones above [L] +export const ROPE_NEIGHBOURS = {0o2616: 0o05716, 0o2617: 0o01201, 0o2620: 0o64752, 0o2621: 0o22007, 0o2622: 0o50000, 0o2623: 0o21777, 0o2624: 0o26063}; + +/** odd parity: the 16th bit makes the count of ones odd */ +export function parityBit(w) { + let n = 0; + for (let b = 0; b < 15; b++) n += (w >> b) & 1; + return n % 2 === 0 ? 1 : 0; +} +/** the 16 bits as stored, most significant data bit first, parity last */ +export function bits16(w) { + const out = []; + for (let b = 14; b >= 0; b--) out.push((w >> b) & 1); + out.push(parityBit(w)); + return out; +} +export const octal = (w, n = 5) => w.toString(8).padStart(n, '0'); +/** a fixed address to its place in the ropes: absolute word, module B1-B6, core and strand (the strand mapping is + * this model's: word w of a module sits on core w mod 512, strand ⌊w / 512⌋) [S] [E] */ +export function ropePlace(bank, addr) { + const off = bank >= 2 && addr >= 0o4000 ? addr - (addr >= 0o6000 ? 0o6000 : 0o4000) : addr - 0o2000; + const abs = bank * AGC.bankWords + off; + const module = Math.floor(abs / WORDS_PER_MODULE); + const w = abs % WORDS_PER_MODULE; + return {abs, module, core: w % AGC.coresPerModule, strand: Math.floor(w / AGC.coresPerModule)}; +} +/** the word stored at a bank-01 address of the excerpt, or a neighbour's */ +export function wordAt(bank, addr) { + const r = ROPE_WORDS.find((x) => x.bank === bank && x.addr === addr); + if (r) return r.word; + return ROPE_NEIGHBOURS[addr] ?? null; +} + +// instruction times in MCT [H] +const TIME = {CAF: 2, CA: 2, CS: 2, TS: 2, INDEX: 2, CCS: 2, TCF: 1, TC: 1, ADS: 2, LXCH: 2, DXCH: 3, MASK: 2, INHINT: 1, DEC: 1, OCT: 1}; +/** + * The instructions NOVAC2 runs to find a core set, given how many are busy (the search starts at core set 0 and + * walks up): [{w (index in ROPE_WORDS), mct (its first MCT, from the call), n (its MCTs), erasable?}]. With all 8 + * busy it falls through to BAILOUT1, whose INDEX Q / CAF 0 fetches the word after TC BAILOUT1: 01202, from the rope. + */ +export function novacTrace(busy) { + const idx = (addr, bank = 1) => ROPE_WORDS.findIndex((x) => x.bank === bank && x.addr === addr); + const out = []; + let t = 0; + const run = (addr, bank = 1, extra = {}) => { + const w = idx(addr, bank); + const op = ROPE_WORDS[w].op.split(' ')[0]; + out.push({w, mct: t, n: TIME[op], ...extra}); + t += TIME[op]; + }; + run(0o2625); + run(0o2626); + run(0o2627); + for (let k = 0; k < EXEC.coreSets; k++) { + run(0o2630); + run(0o2631); + run(0o2632, 1, {erasable: {set: k}}); + if (k >= busy) { + // -0 in PRIORITY: the core set is free, CCS skips three to CORFOUND + for (const a of [0o2636, 0o2637, 0o2640, 0o2641, 0o2642, 0o2643]) run(a); + return {trace: out, found: k, mct: t}; + } + run(0o2633); + run(0o2674); + run(0o2675); + run(0o2676); + if (k < EXEC.coreSets - 1) run(0o2677); + } + for (const a of [0o2700, 0o2701, 0o2702]) run(a); + for (const a of [0o5716, 0o5717, 0o5720, 0o5721, 0o5722]) run(a, 2); + run(0o5723, 2, {fetch: idx(0o2703)}); + return {trace: out, found: -1, mct: t}; +} + +// ------------------------------------------------------------ the sound [Q] +/** Quindar tones: the ground's transmitter keyed with 2,525 Hz, released with 2,475 Hz, 250 ms each */ +export const QUINDAR = {intro: 2525, outro: 2475, ms: 250}; + +// ------------------------------------------------------------ the DSKY [D] [P] +export const DSKY_KEYS = ['VERB', 'NOUN', '+', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'CLR', 'PRO', 'KEY REL', 'ENTR', 'RSET']; +// LM DSKY lamps, left and right columns [D] +export const LAMPS = [ + ['UPLINK ACTY', 'TEMP'], + ['NO ATT', 'GIMBAL LOCK'], + ['STBY', 'PROG'], + ['KEY REL', 'RESTART'], + ['OPR ERR', 'TRACKER'], + ['', 'ALT'], + ['', 'VEL'], +]; +// the crew's keystrokes, s after PDI [J] [E]: Aldrin calls up the Delta-H monitor, reads the alarm code, resets, +// calls the monitor again before the second alarm, then leaves it to Houston +export const CREW_KEYS = [ + [302.6, 'VERB'], [303.0, '1'], [303.4, '6'], [303.9, 'NOUN'], [304.3, '6'], [304.7, '8'], [305.3, 'ENTR'], + [319.6, 'VERB'], [320.0, '0'], [320.4, '5'], [320.9, 'NOUN'], [321.3, '0'], [321.7, '9'], [322.3, 'ENTR'], + [323.6, 'RSET'], + [347.6, 'VERB'], [348.0, '1'], [348.4, '6'], [348.9, 'NOUN'], [349.3, '6'], [349.7, '8'], [350.3, 'ENTR'], + [362.0, 'RSET'], + [581.0, 'RSET'], [590.5, 'RSET'], [601.0, 'RSET'], +]; diff --git a/machines/rope/agc-shared/make-timeline.mjs b/machines/rope/agc-shared/make-timeline.mjs new file mode 100644 index 0000000..878b43f --- /dev/null +++ b/machines/rope/agc-shared/make-timeline.mjs @@ -0,0 +1,32 @@ +// Writes ../agc-film/timeline.json (the film's beats, for review sheets and the score) and ../agc-film/sfx.json (every +// sound event), from the same schedule and Executive log the film draws: the DSKY key presses, the alarms, the +// Quindar tones of the radio calls. node agc-shared/make-timeline.mjs (from machines/rope) +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {QUINDAR} from './machine.js'; +import {DURATION, LOG, SCRIPT, T, filmAt, missionAt, rateAt} from './schedule.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const r = (x) => +x.toFixed(3); +const inFilm = (t) => t >= 0 && t < DURATION - 0.1; +// key clicks: every press of the log heard at a film pace slow enough to hear it as a press +const keys = LOG.keyEv.filter(([s]) => s > missionAt(0)).map(([s]) => filmAt(s)).filter((t) => inFilm(t) && rateAt(t) < 6).map(r); +const alarms = LOG.alarms.map((a) => ({t: r(filmAt(a.s)), code: a.code})).filter((a) => inFilm(a.t)); +// Quindar tones: the start and end of the radio exchanges about the alarm (Armstrong's call, Houston's "go") [J] +const calls = [ + [T.alarm + 0.9, T.alarm + 2.3], + [T.alarm + 3.6, T.alarm + 4.6], + [T.contact + 0.2, T.contact + 1.4], +].map(([a, b]) => [r(a), r(b)]); +const beats = { + views: SCRIPT.filter((e) => e.t > 0).map((e) => e.t), + ropeRead: T.ropeIn, + erasRead: T.erasIn, + alarm: alarms[0].t, + ff: T.ff, + contact: T.contact, +}; +fs.writeFileSync(path.join(HERE, '../agc-film/timeline.json'), JSON.stringify({duration: DURATION, beats})); +fs.writeFileSync(path.join(HERE, '../agc-film/sfx.json'), JSON.stringify({keys, alarms, calls, quindar: QUINDAR})); +console.log(beats, keys.length, 'key presses', alarms.length, 'alarms'); diff --git a/machines/rope/agc-shared/schedule.js b/machines/rope/agc-shared/schedule.js new file mode 100644 index 0000000..3f840ef --- /dev/null +++ b/machines/rope/agc-shared/schedule.js @@ -0,0 +1,87 @@ +// The film's operator script and its one input: the mission time s (seconds after PDI, counted on screen in MCT) as +// a monotone function of film time t. It crawls through two single memory cycles (a word read from the rope, a word +// read and rewritten in erasable memory), runs at about twice real time into the first 1202, then fast-forwards +// through P64 to touchdown. No three.js, so the timeline and the score can import it in node. +import {EVENTS, MCT, ROPE_WORDS, novacTrace} from './machine.js'; +import {jobsAt, simulate} from './executive.js'; + +export const DURATION = 40; +/** the flown log: the crew's keys, the radar's steals on */ +export const LOG = simulate(); +export const ALARM1 = LOG.alarms[0]; + +// the NOVAC call the film slows down on: the first monitor-job request after 307.6 s +const REQ = LOG.jobs.find((j) => j.kind === 'monitor' && j.t0 >= 307.6); +export const NOVAC = {req: REQ, ...novacTrace(REQ.set)}; +const base = Math.round(REQ.t0 / MCT); +const iCaf = NOVAC.trace.findIndex((x) => ROPE_WORDS[x.w].op === 'CAF NO.CORES'); +const iCcs = NOVAC.trace.findIndex((x) => x.erasable); +/** the MCT in which the rope gives CAF NO.CORES (fetched in the last MCT before it runs) */ +export const ROPE_MCT = base + NOVAC.trace[iCaf].mct - 1; +/** the MCT in which CCS PRIORITY reads core set 0's PRIORITY register (and rewrites it) */ +export const ERAS_MCT = base + NOVAC.trace[iCcs].mct + 1; +export const ERAS_JOB = jobsAt(LOG, REQ.t0)[0]; +const sR = ROPE_MCT * MCT; +const sE = ERAS_MCT * MCT; +const R_SLOW = MCT / 4; // one MCT in 4 s of film +export const T = {ropeIn: 10.2, erasIn: 16.0, alarm: 27.6, ff: 32.4, contact: 37.4}; + +const cubicOut = (u) => 1 - (1 - u) ** 3; +const cubicIn = (u) => u ** 3; +const smoother = (u) => u * u * u * (u * (u * 6 - 15) + 10); +// [t0, t1, s0, s1, ease] +const SEGS = [ + [0, 9.6, 306.4, sR + (9.6 - T.ropeIn) * R_SLOW, cubicOut], + [9.6, 14.8, sR + (9.6 - T.ropeIn) * R_SLOW, sR + (14.8 - T.ropeIn) * R_SLOW, (u) => u], + [14.8, 15.6, sR + (14.8 - T.ropeIn) * R_SLOW, sE + (15.6 - T.erasIn) * R_SLOW, (u) => u], + [15.6, 20.2, sE + (15.6 - T.erasIn) * R_SLOW, sE + (20.2 - T.erasIn) * R_SLOW, (u) => u], + [20.2, T.alarm, sE + (20.2 - T.erasIn) * R_SLOW, ALARM1.s, cubicIn], + [T.alarm, T.ff, ALARM1.s, 324.0, (u) => u], + [T.ff, T.contact, 324.0, EVENTS.contact + 0.4, smoother], + [T.contact, DURATION + 1, EVENTS.contact + 0.4, EVENTS.contact + 3.2, (u) => u], +]; +/** mission seconds after PDI at film time t */ +export function missionAt(t) { + const sg = SEGS.find((x) => t < x[1]) ?? SEGS[SEGS.length - 1]; + const u = Math.min(1, Math.max(0, (t - sg[0]) / (sg[1] - sg[0]))); + return sg[2] + (sg[3] - sg[2]) * sg[4](u); +} +/** film time at which the mission reaches s (binary search: s is monotone in t) */ +export function filmAt(s) { + let lo = 0; + let hi = DURATION + 1; + for (let k = 0; k < 50; k++) { + const mid = (lo + hi) / 2; + if (missionAt(mid) < s) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} +/** mission seconds per film second at t */ +export const rateAt = (t) => (missionAt(t + 0.004) - missionAt(Math.max(0, t - 0.004))) / (t > 0.004 ? 0.008 : 0.004); + +// operator moves: camera views and the button pressed for each; `act` are the mechanical states +export const SCRIPT = [ + {t: 0, cam: 'hero'}, + {t: 3.0, cam: 'open', btn: 'tray'}, + {t: 6.8, cam: 'rope', btn: 'rope'}, + {t: 14.6, cam: 'eras', btn: 'eras'}, + {t: 20.2, cam: 'dsky', btn: 'dsky'}, + {t: 24.0, cam: 'exec', btn: 'exec'}, + {t: 32.4, cam: 'hero', btn: 'case'}, +]; +const ramp = (a, b, t) => smoother(Math.min(1, Math.max(0, (t - a) / (b - a)))); +/** the machine's mechanical states at film time t */ +export function actsAt(t) { + return { + open: ramp(3.2, 6.4, t) * (1 - ramp(32.8, 36.4, t)), + rope: ramp(7.0, 8.6, t) * (1 - ramp(16.0, 17.4, t)), + ropeCover: ramp(8.4, 9.2, t) * (1 - ramp(15.6, 16.0, t)), + ropeDetail: t > 8.7 && t < 15.6 ? 1 : 0, + eras: ramp(15.0, 16.0, t) * (1 - ramp(20.4, 21.4, t)), + logic: 0, + gate: 0, + cut: '', + }; +} +export const camAtName = (t) => SCRIPT.filter((e) => e.cam && e.t <= t).pop().cam; diff --git a/machines/rope/agc-shared/score.py b/machines/rope/agc-shared/score.py new file mode 100644 index 0000000..612653b --- /dev/null +++ b/machines/rope/agc-shared/score.py @@ -0,0 +1,71 @@ +"""Score for Rope (the Apollo Guidance Computer, after public sources): the AGC itself makes no sound, so there is no +hum. A very low, dry bed in D the whole way; a dry click on every DSKY key press (from sfx.json, computed by +make-timeline.mjs from the Executive's log); Quindar tones at the start (2,525 Hz) and end (2,475 Hz) of each radio +exchange; the master alarm tone at the first 1202 (a square wave alternating 750 and 2,000 Hz, after the Apollo +Operations Handbook's caution-and-warning description), shorter for the alarms of the fast forward; a soft tick +on each button the operator presses; a low thud at contact. + + node agc-shared/make-timeline.mjs && python3 "$SKILL_DIR/run_sfx.py" agc-shared/score.py +""" + +import json +import math +import os + +from sfx.score_kit import beep, hiss, hz, thud +from sfx.synth import SR, click, pad + +TIMELINE = "../agc-film/timeline.json" +OUT = os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), "rope-score.wav") +TARGET_LUFS = -14.0 +SEED = 1969 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def square_alarm(dur, f_a=750.0, f_b=2000.0, rate=2.5, level=1.0): + """The master alarm: a soft-edged square wave switching between two pitches `rate` times a second.""" + n = int(dur * SR) + out = [] + ph = 0.0 + for k in range(n): + t = k / SR + f = f_a if int(t * rate * 2) % 2 == 0 else f_b + ph += 2 * math.pi * f / SR + s = math.tanh(3.0 * math.sin(ph)) * 0.6 + env = min(1.0, t / 0.01, (dur - t) / 0.04) + out.append(level * s * env) + return out + + +def score(mix, tl): + dur = tl["duration"] + b = tl["beats"] + with open(os.path.join(HERE, "../agc-film/sfx.json"), encoding="utf8") as f: + fx = json.load(f) + # the bed: one key, D, low and dry (no tremolo, no drums) + chords = [(0.0, [hz("D2"), hz("A2"), hz("D3")]), (12.0, [hz("D2"), hz("A2"), hz("E3")]), (24.0, [hz("D2"), hz("A2"), hz("D3")]), (33.0, [hz("D2"), hz("A2"), hz("F#3")])] + left, right = pad(dur, chords, detune=0.25, tremolo=0.0, xfade=1.2) + mix.put_stereo(0, left, right, 0.05) + # the operator's buttons + for t in b["views"]: + mix.put(t, click(2200, 0.022), 0.04) + # the DSKY keys: a dry mechanical click + for i, t in enumerate(fx["keys"]): + mix.put(t, click(1300 + 60 * (i % 3), 0.03), 0.16, pan=0.25) + mix.put(t + 0.004, click(4200, 0.01), 0.05, pan=0.25) + # Quindar tones round each radio exchange, with a little band noise under the open channel + q = fx["quindar"] + for t0, t1 in fx["calls"]: + mix.put(t0, beep(q["intro"], q["ms"] / 1000, 1.0), 0.05) + mix.put(t0 + 0.25, hiss(max(0.1, t1 - t0 - 0.25), 2600, 1.0), 0.006) + mix.put(t1, beep(q["outro"], q["ms"] / 1000, 1.0), 0.05) + # the alarms: the first in full, the fast-forward ones as short bursts + for i, a in enumerate(fx["alarms"]): + if a["t"] >= dur - 0.2: + continue + if i == 0: + mix.put(a["t"], square_alarm(1.6), 0.05) + elif all(abs(a["t"] - x["t"]) > 0.35 for x in fx["alarms"][:i]): + mix.put(a["t"], square_alarm(0.4), 0.03) + # contact + mix.put(b["contact"], thud(70, 0.25, 0.8), 0.22) diff --git a/machines/rope/agc-shared/seg7.js b/machines/rope/agc-shared/seg7.js new file mode 100644 index 0000000..7c9d78a --- /dev/null +++ b/machines/rope/agc-shared/seg7.js @@ -0,0 +1,69 @@ +// Seven-segment digits drawn by code, for the DSKY's electroluminescent panel (a canvas texture in 3D) and for every +// figure of the console (SVG). Segments a-g, plus the DSKY's sign (a vertical bar crossing g makes +). +// +// aaa +// f b +// ggg +// e c +// ddd +const SEG = { + 0: 'abcdef', 1: 'bc', 2: 'abdeg', 3: 'abcdg', 4: 'bcfg', 5: 'acdfg', 6: 'acdefg', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg', + '-': 'g', '+': 'g+', ' ': '', E: 'adefg', r: 'eg', o: 'cdeg', P: 'abefg', A: 'abcefg', L: 'def', H: 'bcefg', F: 'aefg', C: 'adef', +}; +/** the segment polygons of one digit in a w x h box at (x, y), as [[x, y], ...] lists; slant in fractions of w */ +export function digitPolys(ch, x, y, w, h, slant = 0.08) { + const segs = SEG[ch] ?? ''; + const t = Math.max(1, w * 0.17); + const g = t * 0.22; + const sk = (px, py) => [x + px + (h / 2 - py) * slant * (w / h) * 2, y + py]; + const H = (px0, px1, py) => [sk(px0 + g, py), sk(px0 + t / 2 + g, py - t / 2), sk(px1 - t / 2 - g, py - t / 2), sk(px1 - g, py), sk(px1 - t / 2 - g, py + t / 2), sk(px0 + t / 2 + g, py + t / 2)]; + const V = (px, py0, py1) => [sk(px, py0 + g), sk(px + t / 2, py0 + t / 2 + g), sk(px + t / 2, py1 - t / 2 - g), sk(px, py1 - g), sk(px - t / 2, py1 - t / 2 - g), sk(px - t / 2, py0 + t / 2 + g)]; + const P = { + a: H(t / 2, w - t / 2, t / 2), + g: H(t / 2, w - t / 2, h / 2), + d: H(t / 2, w - t / 2, h - t / 2), + f: V(t / 2, t / 2, h / 2), + b: V(w - t / 2, t / 2, h / 2), + e: V(t / 2, h / 2, h - t / 2), + c: V(w - t / 2, h / 2, h - t / 2), + '+': V(w / 2, h / 2 - w * 0.36, h / 2 + w * 0.36), + }; + const all = 'abcdefg+'.split(''); + return all.map((k) => ({k, on: segs.includes(k), pts: P[k]})).filter((s) => s.k !== '+' || s.on); +} +/** draws a string of digits on a 2D canvas context; off segments in `off` (null: not drawn) */ +export function drawDigits(ctx, str, x, y, w, h, gap, on, off = null, slant = 0.08) { + [...str].forEach((ch, i) => { + for (const s of digitPolys(ch, x + i * (w + gap), y, w, h, slant)) { + if (!s.on && !off) continue; + ctx.fillStyle = s.on ? on : off; + ctx.beginPath(); + s.pts.forEach(([px, py], j) => (j ? ctx.lineTo(px, py) : ctx.moveTo(px, py))); + ctx.closePath(); + ctx.fill(); + } + }); +} +/** the same as SVG paths (one string), for the console; '.' and ':' take a narrow cell of their own */ +export function svgDigits(str, x, y, w, h, gap, on, off = null, slant = 0.08) { + let out = ''; + let cx = x; + const r = Math.max(1, w * 0.1); + const dot = (px, py) => ``; + for (const ch of str) { + if (ch === '.' || ch === ':') { + const px = cx + w * 0.12; + out += ch === '.' ? dot(px, y + h - r) : dot(px, y + h * 0.3) + dot(px, y + h * 0.7); + cx += w * 0.24 + gap; + continue; + } + for (const s of digitPolys(ch, cx, y, w, h, slant)) { + if (!s.on && !off) continue; + out += ``; + } + cx += w + gap; + } + return out; +} +/** width of a string drawn by svgDigits */ +export const digitsWidth = (str, w, gap) => [...str].reduce((a, ch) => a + (ch === '.' || ch === ':' ? w * 0.24 : w) + gap, 0) - gap; diff --git a/machines/rope/agc-shared/stage.js b/machines/rope/agc-shared/stage.js new file mode 100644 index 0000000..c8bc45b --- /dev/null +++ b/machines/rope/agc-shared/stage.js @@ -0,0 +1,169 @@ +// Rope's stage: a sheet of the MIT Instrumentation Lab's documentation rather than a studio. Warm ivory paper with a +// faint millimetre grid and a drawing frame (a screen-space background, so the grid stays put like the sheet it is), +// one warm desk lamp low on the left for long soft shadows, a pale fill. The camera is a long lens, close to an +// axonometric projection. frame(fx, cam) poses the machine and draws one frame. +import * as THREE from 'three'; +import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js'; +import {OutputPass} from 'three/addons/postprocessing/OutputPass.js'; +import {RenderPass} from 'three/addons/postprocessing/RenderPass.js'; +import {buildMachine} from './agc.js'; + +export const PAPER = {bg: '#efe8d8', minor: 'rgba(120,96,58,0.075)', major: 'rgba(120,96,58,0.14)', frame: 'rgba(70,58,40,0.55)'}; +const V3 = THREE.Vector3; + +/** the drawing sheet: grid every 6 px ("1 mm"), heavier every 60, a frame with zone letters; 1600 x 900 layout */ +export function paperCanvas(scale = 2) { + const W = 1600; + const H = 900; + const c = document.createElement('canvas'); + c.width = W * scale; + c.height = H * scale; + const g = c.getContext('2d'); + g.scale(scale, scale); + g.fillStyle = PAPER.bg; + g.fillRect(0, 0, W, H); + // a faint warm vignette, like a lamp on paper + const rg = g.createRadialGradient(W * 0.36, H * 0.3, 80, W * 0.5, H * 0.5, W * 0.75); + rg.addColorStop(0, 'rgba(255,250,236,0.5)'); + rg.addColorStop(1, 'rgba(160,130,80,0.10)'); + g.fillStyle = rg; + g.fillRect(0, 0, W, H); + const line = (x0, y0, x1, y1, col, w) => { + g.strokeStyle = col; + g.lineWidth = w; + g.beginPath(); + g.moveTo(x0, y0); + g.lineTo(x1, y1); + g.stroke(); + }; + const m = 14; + for (let x = m; x <= W - m; x += 6) line(x + 0.25, m, x + 0.25, H - m, (x - m) % 60 === 0 ? PAPER.major : PAPER.minor, (x - m) % 60 === 0 ? 0.7 : 0.45); + for (let y = m; y <= H - m; y += 6) line(m, y + 0.25, W - m, y + 0.25, (y - m) % 60 === 0 ? PAPER.major : PAPER.minor, (y - m) % 60 === 0 ? 0.7 : 0.45); + g.strokeStyle = PAPER.frame; + g.lineWidth = 1.2; + g.strokeRect(m, m, W - 2 * m, H - 2 * m); + g.lineWidth = 0.6; + g.strokeRect(m - 5, m - 5, W - 2 * m + 10, H - 2 * m + 10); + return c; +} + +export function createStage({canvas}) { + const renderer = new THREE.WebGLRenderer({canvas, antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance'}); + renderer.toneMapping = THREE.NeutralToneMapping; + renderer.toneMappingExposure = 1.0; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + renderer.localClippingEnabled = true; + const scene = new THREE.Scene(); + const bgTex = new THREE.CanvasTexture(paperCanvas(2)); + bgTex.colorSpace = THREE.SRGBColorSpace; + scene.background = bgTex; + const camera = new THREE.PerspectiveCamera(18, 16 / 9, 0.001, 30); + const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(4, 4, {type: THREE.HalfFloatType, samples: 8})); + composer.addPass(new RenderPass(scene, camera)); + composer.addPass(new OutputPass()); + + // a warm room for the metal to reflect + { + const env = new THREE.Scene(); + env.background = new THREE.Color('#d8cdb5'); + const box = (w, h, pos, col, k) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({color: new THREE.Color(col).multiplyScalar(k), side: THREE.DoubleSide})); + m.position.copy(pos); + m.lookAt(0, 0.5, 0); + env.add(m); + }; + box(10, 10, new V3(-3, 9, 4), '#fff3dc', 2.2); + box(4, 8, new V3(-9, 3, 2), '#ffe9c4', 1.8); + box(4, 8, new V3(9, 3, -3), '#f2efe6', 1.1); + const pm = new THREE.PMREMGenerator(renderer); + scene.environment = pm.fromScene(env, 0.03).texture; + scene.environmentIntensity = 0.75; + pm.dispose(); + } + // the desk lamp: warm, low on the left and in front, long shadows + const lamp = new THREE.DirectionalLight('#ffe2b8', 2.6); + lamp.position.set(-1.6, 1.25, 1.35); + lamp.target.position.set(0, 0, -0.05); + lamp.castShadow = true; + Object.assign(lamp.shadow.camera, {left: -0.9, right: 0.9, top: 0.75, bottom: -0.75, near: 0.5, far: 4.5}); + lamp.shadow.mapSize.set(4096, 4096); + lamp.shadow.radius = 5; + lamp.shadow.bias = -0.0003; + lamp.shadow.normalBias = 0.0015; + scene.add(lamp, lamp.target); + scene.add(new THREE.HemisphereLight('#fff8ea', '#cdbf9f', 1.05)); + const fill = new THREE.DirectionalLight('#e8eef5', 0.45); + fill.position.set(1.4, 0.9, 1.6); + scene.add(fill); + // a lamp that follows the camera when it is inside a module, so the cores read + const inner = new THREE.PointLight('#fff1da', 0, 0.3, 1.5); + scene.add(inner); + + const shade = new THREE.Mesh( + new THREE.PlaneGeometry(8, 8), + new THREE.ShadowMaterial({color: '#5a4630', opacity: 0.3, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -8}), + ); + shade.rotation.x = -Math.PI / 2; + shade.receiveShadow = true; + scene.add(shade); + + const mac = buildMachine(); + scene.add(mac.root); + mac.root.traverse((o) => { + if (o.isMesh && !o.isInstancedMesh) { + o.castShadow = o.castShadow ?? true; + o.receiveShadow = true; + } + }); + + const size = {w: 1600, h: 900, pr: 1}; + const resize = (w, h, pr) => { + size.w = w; + size.h = h; + size.pr = pr; + renderer.setPixelRatio(pr); + renderer.setSize(w, h, false); + composer.setPixelRatio(pr); + composer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }; + // cam: target (tx, ty, tz), azimuth from +z toward +x, elevation, distance (radians, metres), fov, sx/sy screen shift + const frame = (fx, cam) => { + const {tx, ty, tz, az, el, d} = cam; + camera.fov = cam.fov ?? 18; + camera.position.set(tx + d * Math.cos(el) * Math.sin(az), ty + d * Math.sin(el), tz + d * Math.cos(el) * Math.cos(az)); + camera.lookAt(tx, ty, tz); + camera.near = Math.max(0.0005, d * 0.05); + camera.far = Math.max(6, d * 20); + camera.updateProjectionMatrix(); + camera.setViewOffset(size.w, size.h, -(cam.sx ?? 0), -(cam.sy ?? 0), size.w, size.h); + camera.updateMatrixWorld(); + const close = Math.max(0, Math.min(1, (0.3 - d) / 0.2)); + inner.intensity = close * 0.25; + inner.position.copy(camera.position).add(new V3(-0.01, 0.02, 0.01)); + mac.update(fx); + composer.render(); + }; + const pv = new V3(); + const project = (p) => { + pv.copy(p).project(camera); + if (pv.z > 1) return null; + return [(pv.x * 0.5 + 0.5) * size.w, (-pv.y * 0.5 + 0.5) * size.h]; + }; + const setQuality = ({samples, shadow}) => { + if (samples !== undefined) + for (const t of [composer.renderTarget1, composer.renderTarget2]) { + t.samples = samples; + t.dispose(); + } + if (shadow !== undefined) { + lamp.shadow.mapSize.set(shadow, shadow); + lamp.shadow.map?.dispose(); + lamp.shadow.map = null; + } + }; + return {renderer, scene, camera, mac, composer, lamp, resize, frame, project, size, setQuality}; +} diff --git a/machines/rope/check.mjs b/machines/rope/check.mjs new file mode 100644 index 0000000..a661f56 --- /dev/null +++ b/machines/rope/check.mjs @@ -0,0 +1,88 @@ +// Recomputes, from the same functions the page draws with, every figure Rope shows, and fails if one leaves the range +// its source gives. node machines/rope/check.mjs (CI runs it on every push). +import * as M from './agc-shared/machine.js'; +import {simulate} from './agc-shared/executive.js'; + +let bad = 0; +const near = (name, v, lo, hi, unit = '') => { + const ok = v >= lo && v <= hi; + if (!ok) bad++; + const f = (x) => (unit === 'octal' ? `0${x.toString(8)}` : Number(Number(x).toPrecision(7))); + console.log(`${ok ? 'ok ' : 'BAD'} ${name.padEnd(50)} ${f(v)} ${unit === 'octal' ? '' : unit} [${f(lo)} … ${f(hi)}]`); +}; + +// the clock [W] [H]: 2.048 MHz halved, 12 timing pulses per memory cycle +near('memory cycle (MCT)', M.MCT * 1e6, 11.71, 11.72, 'µs'); +near('MCT per second', M.MCT_PER_S, 85333, 85334); +near('addition, 2 MCT', M.ADD_US, 23.43, 23.44, 'µs'); +near('additions per second', M.ADDS_PER_S, 42666, 42667); +// the word [W]: 15 data bits + odd parity +near('word', M.AGC.dataBits + 1, 16, 16, 'bits'); +// the memories [W] [S]: 6 ropes x 512 cores x 12 words = 36,864 words; 2,048 erasable words +near('rope words per module (512 cores x 12)', M.WORDS_PER_MODULE, 6144, 6144); +near('fixed words (6 modules)', M.AGC.ropeModules * M.WORDS_PER_MODULE, 36864, 36864); +near('fixed words = 36 banks of 1,024', 36 * M.AGC.bankWords, 36864, 36864); +near('fixed memory in bits', M.FIXED_BITS, 589824, 589824); +near('sense lines per core (12 strands of 16)', M.AGC.wordsPerCore * 16, M.AGC.senseLinesPerCore, M.AGC.senseLinesPerCore); +near('erasable memory in bits', M.ERASABLE_BITS, 32768, 32768); +// one kind of gate: ~2,800 dual 3-input NOR flat-packs [W] +near('NOR gates', M.AGC.ics * M.AGC.gatesPerIc, 5600, 5600); +// the box [W]: 24 x 12.5 x 6.5 in = 61 x 32 x 17 cm +M.AGC.sizeIn.forEach((x, i) => near(` box dimension ${i + 1}`, x * 0.0254, M.AGC.sizeM[i] - 0.005, M.AGC.sizeM[i] + 0.005, 'm')); +// odd parity on every word of the excerpt [L]: the count of ones, parity included, is odd +let parityBad = 0; +for (const w of M.ROPE_WORDS) if (w.bits.reduce((a, b) => a + b, 0) % 2 !== 1) parityBad++; +near('words with even parity in the excerpt', parityBad, 0, 0); +// the word the film reads, CAF NO.CORES at 01,2627 = 32634 octal [L] +const caf = M.ROPE_WORDS.find((w) => w.op === 'CAF NO.CORES'); +near('CAF NO.CORES at 01,2627', caf.word, 0o32634, 0o32634, 'octal'); +near('its place: module B1', M.ropePlace(caf.bank, caf.addr).module + 1, 1, 1); +// NOVAC2 with all 8 core sets busy falls through to BAILOUT1 and fetches 01202 from the rope +const full = M.novacTrace(8); +near('NOVAC with 8 busy: found', full.found, -1, -1); +near(' the word it fetches from the rope: OCT 1202', M.ROPE_WORDS[full.trace[full.trace.length - 1].fetch].word, 0o1202, 0o1202, 'octal'); +near(' MCTs from NOVAC to the alarm code', full.mct, 60, 140); +// the radar [E1]: 2 angles x 6,400 pulses/s, one MCT each +near('radar steal, theoretical maximum', M.TLOSS_THEORY * 100, 14.9, 15.1, '%'); +near('radar steal used by the model', M.EXEC.tloss * 100, 13, M.EXEC.tlossMax * 100, '%'); +// the duty at the first alarm [E1]: braking 85 % + radar data + V16 N68 3 % + 13 % stolen > 100 % +const duty = M.EXEC.braking + M.EXEC.lrConvert + M.EXEC.n68 + M.EXEC.tloss; +near('duty asked at the 1202', duty * 100, 100.01, 104, '%'); +near('duty without the radar', (duty - M.EXEC.tloss) * 100, 85, 91, '%'); +// the log as flown, against Apollo 11's alarms [R] +const L = simulate(); +const a = L.alarms; +near('first alarm is a 1202', a[0].code, 1202, 1202); +near(' at, s after PDI (flown 317, 102:38:22)', a[0].s, 314, 320, 's'); +near(' core sets busy at it', a[0].busy, 8, 8); +near('second alarm is a 1202', a[1].code, 1202, 1202); +near(' at (flown 357, 102:39:02)', a[1].s, 352, 362, 's'); +const p64 = a.filter((x) => x.s > M.EVENTS.p64 && x.s < M.EVENTS.p66); +near('alarms in P64 (flown 3)', p64.length, 2, 4); +near('alarms in P66 and after (flown 0)', a.filter((x) => x.s >= M.EVENTS.p66).length, 0, 0); +near('alarms between the 2nd and P64 (flown 0)', a.filter((x) => x.s > a[1].s + 1 && x.s < M.EVENTS.p64).length, 0, 0); +for (const r of L.restarts) { + if (r.kept.length !== 1 || r.kept[0].kind !== 'servicer') { + bad++; + console.log(`BAD restart at ${r.s.toFixed(2)} kept ${JSON.stringify(r.kept)}`); + } +} +near('restarts that keep exactly the newest SERVICER', L.restarts.filter((r) => r.kept.length === 1 && r.kept[0].kind === 'servicer').length, L.restarts.length, L.restarts.length); +// the radar off: no alarm at all +near('alarms with the radar steals off', simulate({radar: false}).alarms.length, 0, 0); +// the descent [J] [E1]: the published points +near('altitude at the first alarm (~33,500 ft)', M.descent(a[0].s).h, 32500, 34500, 'ft'); +near('altitude at high gate (7,400 ft)', M.descent(M.EVENTS.p64).h, 7300, 7500, 'ft'); +near('slant range at high gate (~7.8 km [G])', M.descent(M.EVENTS.p64).slantNm * 1852, 7000, 8600, 'm'); +near('altitude at contact', M.descent(M.EVENTS.contact).h, 0, 8, 'ft'); +// Quindar tones [Q] +// the Quindar tones the score plays [Q]: 2,525 Hz to key the ground transmitter, 2,475 Hz to release it, 250 ms +near('Quindar intro tone', M.QUINDAR.intro, 2525, 2525, 'Hz'); +near('Quindar release tone', M.QUINDAR.outro, 2475, 2475, 'Hz'); +near('Quindar tone length', M.QUINDAR.ms, 250, 250, 'ms'); + +if (bad) { + console.error(`\n${bad} figure(s) out of range`); + process.exit(1); +} +console.log('\nevery figure in range'); diff --git a/machines/rope/og.png b/machines/rope/og.png new file mode 100644 index 0000000..9896185 Binary files /dev/null and b/machines/rope/og.png differ diff --git a/package-lock.json b/package-lock.json index b4c6f1b..84530b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fontsource-variable/inter": "5.3.0", + "@fontsource/barlow-condensed": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "esbuild": "0.28.1", @@ -441,6 +442,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/barlow-condensed": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz", + "integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/instrument-serif": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.3.0.tgz", diff --git a/package.json b/package.json index 12c5ea9..62775e4 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "description": "Exploded: real machines, built from code with their real numbers, that you can take apart in the browser.", "license": "MIT", "scripts": { - "check": "node machines/spindl/check.mjs && node machines/quantum/check.mjs && node scripts/test-root-lang.mjs", + "check": "node machines/spindl/check.mjs && node machines/quantum/check.mjs && node machines/rope/check.mjs && node scripts/test-root-lang.mjs", "build": "node scripts/build.mjs", "test": "npm run check && npm run build" }, "dependencies": { "@fontsource-variable/inter": "5.3.0", + "@fontsource/barlow-condensed": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "esbuild": "0.28.1", diff --git a/scripts/build.mjs b/scripts/build.mjs index 0e186a3..51bface 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -57,6 +57,7 @@ const bundle = async (entry) => { write: false, target: 'es2022', legalComments: 'none', + loader: {'.woff2': 'base64'}, // a machine's own font, as the teardown kit's build loads it logLevel: 'warning', }); return res.outputFiles[0].text.replace(/<\/script/g, '<\\/script'); diff --git a/scripts/machines.mjs b/scripts/machines.mjs index a73ce2c..485c80f 100644 --- a/scripts/machines.mjs +++ b/scripts/machines.mjs @@ -1,6 +1,35 @@ // The collection: one entry per machine, newest first (the gallery's order). Each machine lives in machines//, // its live page's entry point is `entry`, its share card `og` (1200 x 630, also the gallery's thumbnail). export const MACHINES = [ + { + slug: 'rope', + name: 'Rope', + entry: 'machines/rope/agc-live/main.js', + og: 'machines/rope/og.png', + meta: { + en: { + title: 'Rope · the Apollo Guidance Computer, taken apart', + line: 'The Apollo Guidance Computer and its DSKY: a program woven into core rope, one kind of gate, and the 1202 alarm of Apollo 11.', + description: + 'The Apollo Guidance Computer you can take apart in the browser: a word read from the core rope bit by bit, an erasable core read and rewritten, the Executive running out of core sets as the radar steals cycles, the 1202 of Apollo 11. A model after public sources (MIT Instrumentation Lab, NASA); not affiliated with NASA or MIT.', + locale: 'en_US', + }, + fr: { + title: 'Rope · l’ordinateur de bord d’Apollo, démonté', + line: 'L’Apollo Guidance Computer et son DSKY : un programme tissé dans la mémoire à tores, un seul type de porte, et l’alarme 1202 d’Apollo 11.', + description: + 'L’Apollo Guidance Computer à démonter dans le navigateur : un mot lu bit par bit dans la core rope, un tore effaçable lu puis réécrit, l’Executive à court de core sets quand le radar vole des cycles, le 1202 d’Apollo 11. Un modèle d’après les sources publiques (MIT Instrumentation Lab, NASA) ; sans lien avec la NASA ni le MIT.', + locale: 'fr_FR', + }, + es: { + title: 'Rope · el ordenador de guiado del Apolo, desmontado', + line: 'El Apollo Guidance Computer y su DSKY: un programa tejido en memoria de núcleos, un solo tipo de puerta y la alarma 1202 del Apolo 11.', + description: + 'El Apollo Guidance Computer para desmontar en el navegador: una palabra leída bit a bit en la core rope, un núcleo borrable leído y reescrito, el Executive sin core sets mientras el radar roba ciclos, el 1202 del Apolo 11. Un modelo según fuentes públicas (MIT Instrumentation Lab, NASA); sin relación con la NASA ni el MIT.', + locale: 'es_ES', + }, + }, + }, { slug: 'quantum', name: 'Quantum', @@ -67,7 +96,7 @@ export const GALLERY = { lead: 'Real machines, built from code with their real numbers, that you can take apart in the browser.', sub: 'Every gauge reads the same model that moves the parts. Left alone, each one plays a guided tour; any click hands control over.', open: 'Open', - description: 'Real machines, built from code with their real numbers, that you can take apart in the browser: a hard drive, a photonic quantum computer.', + description: 'Real machines, built from code with their real numbers, that you can take apart in the browser: a hard drive, a photonic quantum computer, the Apollo Guidance Computer.', locale: 'en_US', }, fr: { @@ -75,7 +104,7 @@ export const GALLERY = { lead: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur.', sub: 'Chaque jauge lit le modèle qui fait bouger les pièces. Laissée seule, chacune joue une visite guidée ; un clic suffit pour prendre la main.', open: 'Ouvrir', - description: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur : un disque dur, un ordinateur quantique photonique.', + description: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur : un disque dur, un ordinateur quantique photonique, l’ordinateur de bord d’Apollo.', locale: 'fr_FR', }, es: { @@ -83,7 +112,7 @@ export const GALLERY = { lead: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador.', sub: 'Cada indicador lee el mismo modelo que mueve las piezas. Si la dejas sola, cada una hace una visita guiada; cualquier clic te da el control.', open: 'Abrir', - description: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador: un disco duro, un ordenador cuántico fotónico.', + description: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador: un disco duro, un ordenador cuántico fotónico, el ordenador de guiado del Apolo.', locale: 'es_ES', }, };