Buckets:
| <html lang="en-us"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-PXHK7Q7G3Z"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag() { | |
| dataLayer.push(arguments); | |
| } | |
| gtag("js", new Date()); | |
| gtag("config", "G-PXHK7Q7G3Z"); | |
| </script> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> | |
| <meta content="text/html; charset=utf-8" http-equiv="Content-Type"> | |
| <title>kisakcod webport</title> | |
| <style> | |
| @font-face { | |
| font-family: "Conduit ITC"; | |
| src: url("assets/conduit-itc-regular.otf") format("opentype"); | |
| font-weight: 400; | |
| font-style: normal; | |
| font-display: swap; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background: #000; | |
| width: 100%; | |
| height: 100%; | |
| min-width: 100%; | |
| min-height: 100%; | |
| overflow: hidden; | |
| font-family: "Conduit ITC", sans-serif; | |
| font-size: 18px; | |
| } | |
| body { | |
| position: fixed; | |
| inset: 0; | |
| } | |
| #canvas { | |
| position: fixed; | |
| inset: 0; | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| width: 100vw; | |
| height: 100vh; | |
| width: 100dvw; | |
| height: 100dvh; | |
| max-width: none; | |
| max-height: none; | |
| margin: 0; | |
| padding: 0; | |
| border: 0; | |
| outline: 0; | |
| /* | |
| * Fill the entire browser viewport. | |
| * The game's internal framebuffer remains controlled by | |
| * KisakCOD/Emscripten. | |
| */ | |
| object-fit: fill; | |
| cursor: none; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| -webkit-touch-callout: none; | |
| } | |
| #installer-panel { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(16, 11, 20, .96); | |
| color: #d8d8d8; | |
| z-index: 3; | |
| overflow: auto; | |
| padding: 36px 40px 56px; | |
| box-sizing: border-box; | |
| text-align: center; | |
| } | |
| #installer-panel h1 { | |
| margin: 0 0 12px; | |
| font-size: 32px; | |
| color: #fff; | |
| letter-spacing: .06em; | |
| } | |
| #installer-panel p.hint { | |
| color: #9aa; | |
| max-width: 52em; | |
| font-size: 18px; | |
| line-height: 1.45; | |
| margin: 0 auto 24px; | |
| } | |
| #installer-panel button { | |
| background: #6ec26e; | |
| color: #183318; | |
| border: none; | |
| padding: 9px 13px; | |
| margin: 6px; | |
| font-size: 18px; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| } | |
| #installer-panel button:disabled { | |
| opacity: .4; | |
| cursor: default; | |
| } | |
| #installer-panel button.danger { | |
| background: #3a1c1c; | |
| color: #fcc; | |
| } | |
| #installer-panel button.secondary { | |
| background: #333; | |
| color: #ddd; | |
| } | |
| #installer-panel .row { | |
| display: flex; | |
| justify-content: center; | |
| align-items: baseline; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin: 12px auto; | |
| } | |
| #install-options { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| #install-options[hidden] { | |
| display: none; | |
| } | |
| #install-copy-size { | |
| color: #aaa; | |
| font-size: 16px; | |
| margin: 12px auto; | |
| } | |
| #progress-wrap { | |
| max-width: 760px; | |
| margin: 22px auto 0; | |
| } | |
| #progress-bar { | |
| width: 100%; | |
| height: 22px; | |
| background: #222; | |
| border: 1px solid #444; | |
| box-sizing: border-box; | |
| overflow: hidden; | |
| } | |
| #progress-fill { | |
| width: 0%; | |
| height: 100%; | |
| background: #6ec26e; | |
| transition: width .08s linear; | |
| } | |
| #progress-text { | |
| margin-top: 8px; | |
| color: #aaa; | |
| font-size: 16px; | |
| min-height: 20px; | |
| } | |
| #download-status { | |
| margin-top: 14px; | |
| color: #ddd; | |
| font-size: 18px; | |
| min-height: 22px; | |
| } | |
| #download-detail { | |
| margin-top: 5px; | |
| color: #888; | |
| font-size: 14px; | |
| min-height: 18px; | |
| } | |
| #status { | |
| color: #ddd; | |
| margin-top: 10px; | |
| min-height: 22px; | |
| } | |
| #install-log { | |
| white-space: pre-wrap; | |
| font-size: 14px; | |
| color: #9a9; | |
| margin: 16px auto 0; | |
| max-width: 68em; | |
| max-height: 260px; | |
| overflow: auto; | |
| text-align: left; | |
| } | |
| .small { | |
| color: #777; | |
| font-size: 14px; | |
| } | |
| #fullscreen-button { | |
| display: none; | |
| position: absolute; | |
| right: 20px; | |
| top: 20px; | |
| z-index: 5; | |
| background: #6ec26e; | |
| color: #183318; | |
| border: none; | |
| border-radius: 2px; | |
| padding: 8px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| font-size: 18px; | |
| } | |
| body.game-running:not(.is-fullscreen) #fullscreen-button { | |
| display: block; | |
| } | |
| body.game-running #installer-panel { | |
| display: none ; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="installer-panel"> | |
| <h1>KisakCOD</h1> | |
| <p class="hint"> Install the game files from the split ZIP archive. The archive is processed piece-by-piece and extracted directly into browser storage. </p> | |
| <div class="row"><button id="install-button" type="button"> Install Game </button><button id="play-button" type="button" disabled> Play </button><button id="wipe-data-button" type="button" class="danger"> Wipe data </button></div> | |
| <div id="install-options" hidden> | |
| <div class="row"><label><input id="include-audio" type="checkbox" checked> Audio </label><label><input id="include-video" type="checkbox"> Video </label></div> | |
| <div id="install-copy-size"></div> | |
| <div id="progress-wrap"> | |
| <div id="progress-bar"> | |
| <div id="progress-fill"></div> | |
| </div> | |
| <div id="progress-text"> Waiting... </div> | |
| <div id="download-status"></div> | |
| <div id="download-detail"></div> | |
| </div> | |
| </div> | |
| <div id="status"></div> | |
| <div id="install-log"></div> | |
| <div class="small"> Game data is stored locally using Origin Private File System. </div> | |
| </div><canvas id="canvas" tabindex="-1" width="1280" height="720"></canvas><button id="fullscreen-button" type="button"> Fullscreen </button><template id="module-script-template"> | |
| <script async src="kisakcod-sp.js"></script> | |
| </template> | |
| <script> | |
| ; | |
| /* ============================================================ | |
| UI | |
| ============================================================ */ | |
| const canvas = document.getElementById("canvas"); | |
| const fullscreenButton = document.getElementById("fullscreen-button"); | |
| const installerPanel = document.getElementById("installer-panel"); | |
| const installButton = document.getElementById("install-button"); | |
| const playButton = document.getElementById("play-button"); | |
| const wipeButton = document.getElementById("wipe-data-button"); | |
| const installOptions = document.getElementById("install-options"); | |
| const includeAudio = document.getElementById("include-audio"); | |
| const includeVideo = document.getElementById("include-video"); | |
| const installCopySize = document.getElementById("install-copy-size"); | |
| const progressFill = document.getElementById("progress-fill"); | |
| const progressText = document.getElementById("progress-text"); | |
| const downloadStatus = document.getElementById("download-status"); | |
| const downloadDetail = document.getElementById("download-detail"); | |
| const statusElement = document.getElementById("status"); | |
| const installLog = document.getElementById("install-log"); | |
| function log(message) { | |
| installLog.textContent += message + "\n"; | |
| installLog.scrollTop = installLog.scrollHeight; | |
| } | |
| function status(message) { | |
| statusElement.textContent = message; | |
| } | |
| function setProgress(value, text) { | |
| value = Math.max(0, Math.min(1, value)); | |
| progressFill.style.width = (value * 100).toFixed(2) + "%"; | |
| if (text !== undefined) { | |
| progressText.textContent = text; | |
| } | |
| } | |
| function setDownloadUI(main, detail) { | |
| downloadStatus.textContent = main || ""; | |
| downloadDetail.textContent = detail || ""; | |
| } | |
| /* ============================================================ | |
| KEYBOARD SHORTCUTS | |
| ============================================================ */ | |
| const blockedShortcuts = new Set(["KeyD", "KeyW", "KeyT", "KeyN", "KeyS", "KeyP", "KeyR", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyU"]); | |
| const blockedFunctionKeys = new Set(["F1", "F3", "F5", "F11"]); | |
| window.addEventListener("keydown", event => { | |
| if ( | |
| ( | |
| (event.ctrlKey || event.metaKey) && blockedShortcuts.has(event.code)) || blockedFunctionKeys.has(event.key)) { | |
| if (document.body.classList.contains("game-running")) { | |
| event.preventDefault(); | |
| } | |
| } | |
| }, true); | |
| /* ============================================================ | |
| FULLSCREEN | |
| ============================================================ */ | |
| fullscreenButton.addEventListener("click", async () => { | |
| try { | |
| if (!document.fullscreenElement) { | |
| await document.documentElement.requestFullscreen(); | |
| } else { | |
| await document.exitFullscreen(); | |
| } | |
| try { | |
| await navigator.keyboard?.lock?.(); | |
| } catch {} | |
| } catch {} | |
| }); | |
| document.addEventListener("fullscreenchange", | |
| () => { | |
| const fullscreen = !!document.fullscreenElement; | |
| document.body.classList.toggle("is-fullscreen", fullscreen); | |
| if (fullscreen) { | |
| try { | |
| navigator.keyboard?.lock?.().catch(() => {}); | |
| } catch {} | |
| } | |
| resizeGameCanvas(); | |
| }); | |
| /* ============================================================ | |
| CANVAS RESIZE | |
| ============================================================ */ | |
| function resizeGameCanvas() { | |
| /* | |
| * Only change the CSS/display size. | |
| * | |
| * KisakCOD/Emscripten continues to own the internal | |
| * framebuffer dimensions. | |
| */ | |
| canvas.style.left = "0"; | |
| canvas.style.top = "0"; | |
| canvas.style.transform = "none"; | |
| canvas.style.width = "100vw"; | |
| canvas.style.height = "100vh"; | |
| if (window.visualViewport) { | |
| canvas.style.width = "100dvw"; | |
| canvas.style.height = "100dvh"; | |
| } | |
| } | |
| window.addEventListener("resize", resizeGameCanvas, { | |
| passive: true | |
| }); | |
| if (window.visualViewport) { | |
| window.visualViewport.addEventListener("resize", resizeGameCanvas, { | |
| passive: true | |
| }); | |
| } | |
| resizeGameCanvas(); | |
| /* ============================================================ | |
| EMSCRIPTEN MODULE | |
| ============================================================ */ | |
| var Module = { | |
| canvas: canvas, | |
| locateFile: path => path + "?v=creditsmute1", | |
| kisakLanUrl: ("https:" === location.protocol ? "wss://" : "ws://") + location.host + "/lan", | |
| print: console.log, | |
| printErr: console.log, | |
| setStatus() {}, | |
| onAbort: message => { | |
| console.log("abort: " + message); | |
| status("Game aborted: " + message); | |
| } | |
| }; | |
| /* ============================================================ | |
| ORIGINAL KISAKCOD INPUT BRIDGE | |
| IMPORTANT: | |
| This is based directly on the original HTML supplied by | |
| the user. The Kisak input memory layout and event queue | |
| are intentionally preserved. | |
| ============================================================ */ | |
| let inputInstalled = false; | |
| window.__kisakInstallInput = function() { | |
| var base = 0 | Module.kisakInputBase; | |
| if (!base || inputInstalled) { | |
| return; | |
| } | |
| inputInstalled = true; | |
| canvas.style.cursor = "default"; | |
| let mouseDown = false; | |
| var wantLockOff = Module.kisakWantLockOff >> 2; | |
| function getHeap32() { | |
| if (Module.HEAP32) return Module.HEAP32; | |
| var memory = Module.wasmMemory; | |
| if (memory && memory.buffer) { | |
| return new Int32Array(memory.buffer); | |
| } | |
| throw new Error("no HEAP32/wasmMemory"); | |
| } | |
| var lastX = 0; | |
| var lastY = 0; | |
| var hasMousePosition = false; | |
| /* | |
| * ORIGINAL MOUSE POSITION FUNCTION | |
| * | |
| * This is intentionally kept equivalent to the | |
| * original webport. | |
| */ | |
| function updateMouse(event, isButtonEvent) { | |
| var heap = getHeap32(); | |
| var base32 = base >> 2; | |
| if (document.pointerLockElement === canvas) { | |
| if (isButtonEvent) return; | |
| var dx = 0 | event.movementX; | |
| var dy = 0 | event.movementY; | |
| if (dx > 80) dx = 80; | |
| else if (dx < -80) dx = -80; | |
| if (dy > 80) dy = 80; | |
| else if (dy < -80) dy = -80; | |
| heap[base32 + 2] += dx; | |
| heap[base32 + 3] += dy; | |
| return; | |
| } | |
| var rect = canvas.getBoundingClientRect(); | |
| var width = rect.width || 1; | |
| var height = rect.height || 1; | |
| var nx = (event.clientX - rect.left) / width; | |
| var ny = (event.clientY - rect.top) / height; | |
| if (nx < 0) nx = 0; | |
| if (ny < 0) ny = 0; | |
| if (nx > 1) nx = 1; | |
| if (ny > 1) ny = 1; | |
| var logicalWidth = 0 | heap[base32 + 6]; | |
| var logicalHeight = 0 | heap[base32 + 7]; | |
| if (logicalWidth < 64) logicalWidth = 1280; | |
| if (logicalHeight < 64) logicalHeight = 720; | |
| var x = nx * logicalWidth | 0; | |
| var y = ny * logicalHeight | 0; | |
| heap[base32] = x; | |
| heap[base32 + 1] = y; | |
| if (!isButtonEvent && hasMousePosition) { | |
| heap[base32 + 2] += x - lastX; | |
| heap[base32 + 3] += y - lastY; | |
| } | |
| lastX = x; | |
| lastY = y; | |
| hasMousePosition = true; | |
| } | |
| /* | |
| * ORIGINAL POINTER-LOCK STATE | |
| */ | |
| function getWantLock() { | |
| return 0 | getHeap32()[ | |
| (base >> 2) + wantLockOff]; | |
| } | |
| function updateCursor() { | |
| var wantLock = getWantLock(); | |
| canvas.style.cursor = wantLock ? "none" : "default"; | |
| if (!wantLock && document.pointerLockElement === canvas) { | |
| try { | |
| document.exitPointerLock(); | |
| } catch {} | |
| } | |
| } | |
| /* | |
| * ORIGINAL INPUT QUEUE | |
| * | |
| * These offsets are intentionally unchanged. | |
| */ | |
| function pushKeyEvent(type, keyCode, character) { | |
| var heap = getHeap32(); | |
| var base32 = base >> 2; | |
| var queueState = heap[base32 + 8] >>> 0; | |
| var index = queueState & 63; | |
| heap[base32 + 10 + index] = type; | |
| heap[base32 + 10 + 64 + index] = keyCode; | |
| heap[base32 + 10 + 128 + index] = character; | |
| heap[base32 + 8] = queueState + 1; | |
| } | |
| /* | |
| * AUTOMATION | |
| */ | |
| if (new URLSearchParams(location.search).has("automation")) { | |
| window.__kisakTestKey = function(keyCode, down, character) { | |
| pushKeyEvent(down ? 1 : 0, 0 | keyCode, 0 | character); | |
| }; | |
| } | |
| /* | |
| * MOUSE MOVE | |
| */ | |
| canvas.addEventListener("mousemove", function(event) { | |
| updateCursor(); | |
| updateMouse(event, false); | |
| }); | |
| /* | |
| * ORIGINAL MOUSE DOWN | |
| * | |
| * This is the important part for menu clicks. | |
| */ | |
| canvas.addEventListener("mousedown", function(event) { | |
| mouseDown = true; | |
| try { | |
| canvas.focus(); | |
| } catch {} | |
| updateCursor(); | |
| if (getWantLock() && document.pointerLockElement !== canvas) { | |
| try { | |
| var lockPromise = canvas.requestPointerLock(); | |
| if (lockPromise && lockPromise.catch) { | |
| lockPromise.catch(function() {}); | |
| } | |
| } catch {} | |
| } | |
| /* | |
| * Update the game's actual mouse position | |
| * before sending the button event. | |
| */ | |
| updateMouse(event, true); | |
| /* | |
| * ORIGINAL BUTTON MASK OFFSET. | |
| * | |
| * Do not replace this with a guessed layout. | |
| */ | |
| getHeap32()[4 + (base >> 2)] = 0 | event.buttons; | |
| /* | |
| * ORIGINAL MOUSE BUTTON EVENT. | |
| * | |
| * 256 + browser mouse button. | |
| */ | |
| pushKeyEvent(1, 256 + (0 | event.button), 0); | |
| event.preventDefault(); | |
| }); | |
| /* | |
| * ORIGINAL MOUSE UP | |
| */ | |
| canvas.addEventListener("mouseup", function(event) { | |
| updateMouse(event, true); | |
| getHeap32()[4 + (base >> 2)] = 0 | event.buttons; | |
| pushKeyEvent(0, 256 + (0 | event.button), 0); | |
| event.preventDefault(); | |
| }); | |
| /* | |
| * POINTER LOCK | |
| */ | |
| document.addEventListener("pointerlockchange", function() { | |
| var heap = getHeap32(); | |
| var base32 = base >> 2; | |
| heap[base32 + 2] = 0; | |
| heap[base32 + 3] = 0; | |
| hasMousePosition = false; | |
| }); | |
| /* | |
| * WHEEL | |
| */ | |
| canvas.addEventListener("wheel", function(event) { | |
| getHeap32()[5 + (base >> 2)] += event.deltaY > 0 ? 1 : event.deltaY < 0 ? -1 : 0; | |
| event.preventDefault(); | |
| }, { | |
| passive: false | |
| }); | |
| /* | |
| * ORIGINAL KEYBOARD DOWN | |
| * | |
| * The original webport intentionally gates keyboard | |
| * input using mouseDown. | |
| */ | |
| window.addEventListener("keydown", function(event) { | |
| if (mouseDown && event.key !== "F12") { | |
| var character = event.key && event.key.length === 1 ? event.key.charCodeAt(0) : 0; | |
| pushKeyEvent(1, 0 | event.keyCode, character); | |
| if (event.key === "Tab" || event.key === " " || event.key.indexOf("Arrow") === 0 || event.key === "Backspace") { | |
| event.preventDefault(); | |
| } | |
| } | |
| }); | |
| /* | |
| * ORIGINAL KEYBOARD UP | |
| */ | |
| window.addEventListener("keyup", function(event) { | |
| if (mouseDown) { | |
| pushKeyEvent(0, 0 | event.keyCode, 0); | |
| } | |
| }); | |
| /* | |
| * ORIGINAL OUTSIDE-CANVAS MOUSE HANDLING | |
| * | |
| * Preserved because it controls when keyboard input | |
| * becomes active. | |
| */ | |
| window.addEventListener("mousedown", function(event) { | |
| if (event.target !== canvas) { | |
| mouseDown = false; | |
| } | |
| }, true); | |
| updateCursor(); | |
| setInterval(updateCursor, 100); | |
| }; | |
| /* ============================================================ | |
| SPLIT ZIP CONFIG | |
| ============================================================ */ | |
| const PART_COUNT = 329; | |
| const PART_PREFIX = "game-files/cod4-modern-warfare.zip.part"; | |
| const PART_DIGITS = 3; | |
| /* ============================================================ | |
| ARCHIVE PREFLIGHT | |
| ------------------------------------------------------------ | |
| The installer expects PART_COUNT sequential parts. If the | |
| upload is incomplete (parts missing from the tail), fail | |
| early with a clear message instead of a raw 404 mid-install. | |
| ============================================================ */ | |
| function partUrlFor(index) { | |
| return (PART_PREFIX + String(index + 1).padStart(PART_DIGITS, "0")); | |
| } | |
| async function partAvailable(index) { | |
| const url = partUrlFor(index); | |
| // A ranged GET works even on hosts that reject HEAD. | |
| try { | |
| const response = await fetch(url, { | |
| method: "GET", | |
| cache: "no-store", | |
| headers: { | |
| Range: "bytes=0-0" | |
| } | |
| }); | |
| // Drain/cancel so the connection is released. | |
| try { | |
| if (response.body) await response.body.cancel(); | |
| } catch {} | |
| return (response.status === 200 || response.status === 206); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| // Parts are contiguous (part001..partN); binary-search the | |
| // index of the highest part that actually exists. | |
| async function findLastAvailablePart(expectedCount) { | |
| if (!(await partAvailable(0))) { | |
| return 0; | |
| } | |
| if (await partAvailable(expectedCount - 1)) { | |
| return expectedCount; | |
| } | |
| let low = 0; // known present | |
| let high = expectedCount - 1; // known missing | |
| while (high - low > 1) { | |
| const middle = (low + high) >> 1; | |
| if (await partAvailable(middle)) { | |
| low = middle; | |
| } else { | |
| high = middle; | |
| } | |
| } | |
| return low + 1; | |
| } | |
| // Prefer a static manifest so we never probe (and never log) | |
| // missing-part 404s. Falls back to boundary probing if absent. | |
| async function countAvailableParts() { | |
| try { | |
| const response = await fetch("game-files/parts-manifest.json", { | |
| cache: "no-store" | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| const available = Number(data?.available); | |
| if (Number.isSafeInteger(available) && available >= 0) { | |
| return available; | |
| } | |
| } | |
| } catch {} | |
| // No usable manifest: fall back to probing. | |
| return await findLastAvailablePart(PART_COUNT); | |
| } | |
| async function assertArchiveComplete() { | |
| setDownloadUI("Verifying COD4 archive...", `Expecting ${PART_COUNT} parts`); | |
| const available = await countAvailableParts(); | |
| if (available < PART_COUNT) { | |
| const missing = PART_COUNT - available; | |
| const approxBytes = available > 0 ? available * 20971520 : 0; | |
| const message = `This COD4 build is incomplete: ${available} of ${PART_COUNT} ` + `archive parts are present (${missing} missing). ` + `The game data cannot be assembled without every part, ` + `so installation can't continue. Re-upload the full ` + `part set (cod4-modern-warfare.zip.part001–` + `${String(PART_COUNT).padStart(PART_DIGITS, "0")}).`; | |
| const error = new Error(message); | |
| error.incompleteArchive = true; | |
| error.availableParts = available; | |
| error.expectedParts = PART_COUNT; | |
| error.approxBytesPresent = approxBytes; | |
| throw error; | |
| } | |
| } | |
| /* ============================================================ | |
| FAST SPLIT ZIP READER | |
| ============================================================ */ | |
| class SplitZipReader { | |
| constructor(partCount, prefix = PART_PREFIX) { | |
| this.partCount = partCount; | |
| this.prefix = prefix; | |
| this.offsets = new Array(partCount + 1); | |
| this.offsets[0] = 0; | |
| this.totalSize = 0; | |
| this.loadedPart = -1; | |
| this.loadedBuffer = null; | |
| this.loadingPart = -1; | |
| this.loadingPromise = null; | |
| this.bytesDownloaded = 0; | |
| } | |
| partUrl(index) { | |
| if (!Number.isInteger(index) || index < 0 || index >= this.partCount) { | |
| throw new RangeError("Invalid archive part: " + index); | |
| } | |
| return (this.prefix + String(index + 1).padStart(PART_DIGITS, "0")); | |
| } | |
| async getPartSize(index) { | |
| const url = this.partUrl(index); | |
| try { | |
| const head = await fetch(url, { | |
| method: "HEAD", | |
| cache: "no-store" | |
| }); | |
| if (head.ok) { | |
| const length = head.headers.get("content-length"); | |
| const size = Number(length); | |
| if (Number.isSafeInteger(size) && size > 0) { | |
| return size; | |
| } | |
| } | |
| } catch {} | |
| const response = await fetch(url, { | |
| cache: "no-store" | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Could not check archive part ${index + 1}: HTTP ${response.status}`); | |
| } | |
| if (!response.body) { | |
| throw new Error(`Archive part ${index + 1} has no readable body.`); | |
| } | |
| const reader = response.body.getReader(); | |
| let size = 0; | |
| try { | |
| while (true) { | |
| const { | |
| value, | |
| done | |
| } = await reader.read(); | |
| if (done) break; | |
| if (value) size += value.byteLength; | |
| } | |
| } finally { | |
| try { | |
| reader.releaseLock(); | |
| } catch {} | |
| } | |
| return size; | |
| } | |
| async initialize() { | |
| let total = 0; | |
| this.offsets[0] = 0; | |
| for (let i = 0; i < this.partCount; i++) { | |
| const partNumber = i + 1; | |
| setProgress( | |
| (i / this.partCount) * 0.12, `Checking COD4 part ${partNumber} / ${this.partCount}`); | |
| setDownloadUI(`Checking COD4 part ${partNumber} / ${this.partCount}`, "Reading archive metadata..."); | |
| const size = await this.getPartSize(i); | |
| if (!Number.isSafeInteger(size) || size <= 0) { | |
| throw new Error(`Invalid size for archive part ${partNumber}.`); | |
| } | |
| total += size; | |
| this.offsets[i + 1] = total; | |
| } | |
| this.totalSize = total; | |
| if (!Number.isSafeInteger(this.totalSize)) { | |
| throw new Error("Split ZIP is too large for safe JavaScript offsets."); | |
| } | |
| setProgress(0.12, "Archive metadata ready."); | |
| setDownloadUI("Archive metadata ready.", `${this.partCount} parts • ${formatBytes(this.totalSize)} total`); | |
| } | |
| findPart(position) { | |
| let low = 0; | |
| let high = this.partCount - 1; | |
| while (low <= high) { | |
| const middle = (low + high) >> 1; | |
| if (position >= this.offsets[middle] && position < this.offsets[middle + 1]) { | |
| return middle; | |
| } | |
| if (position < this.offsets[middle]) { | |
| high = middle - 1; | |
| } else { | |
| low = middle + 1; | |
| } | |
| } | |
| throw new Error("Could not locate archive part."); | |
| } | |
| async loadPart(index) { | |
| if (this.loadedPart === index && this.loadedBuffer) { | |
| return this.loadedBuffer; | |
| } | |
| if (this.loadingPart === index && this.loadingPromise) { | |
| return this.loadingPromise; | |
| } | |
| this.loadingPart = index; | |
| this.loadingPromise = (async () => { | |
| const url = this.partUrl(index); | |
| const response = await fetch(url, { | |
| cache: "force-cache" | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to download COD4 part ${index + 1}: HTTP ${response.status}`); | |
| } | |
| if (response.body) { | |
| const reader = response.body.getReader(); | |
| const chunks = []; | |
| let totalLoaded = 0; | |
| const expected = Number(response.headers.get("content-length")); | |
| while (true) { | |
| const { | |
| value, | |
| done | |
| } = await reader.read(); | |
| if (done) break; | |
| if (value && value.byteLength) { | |
| chunks.push(value); | |
| totalLoaded += value.byteLength; | |
| const partSize = this.offsets[index + 1] - this.offsets[index]; | |
| const partFraction = expected > 0 ? totalLoaded / expected : totalLoaded / Math.max(1, partSize); | |
| const overall = (index + Math.min(1, partFraction)) / this.partCount; | |
| const displayProgress = 0.12 + overall * 0.48; | |
| setProgress(displayProgress, `Downloading COD4 part ${index + 1} / ${this.partCount}`); | |
| setDownloadUI(`Downloading COD4 part ${index + 1} / ${this.partCount}`, `${formatBytes(totalLoaded)}${expected > 0 ? " / " + formatBytes(expected) : ""}`); | |
| } | |
| } | |
| const buffer = new Uint8Array(totalLoaded); | |
| let position = 0; | |
| for (const chunk of chunks) { | |
| buffer.set(chunk, position); | |
| position += chunk.byteLength; | |
| } | |
| this.loadedPart = index; | |
| this.loadedBuffer = buffer; | |
| this.bytesDownloaded += buffer.byteLength; | |
| return buffer; | |
| } | |
| const buffer = new Uint8Array(await response.arrayBuffer()); | |
| this.loadedPart = index; | |
| this.loadedBuffer = buffer; | |
| this.bytesDownloaded += buffer.byteLength; | |
| return buffer; | |
| })(); | |
| try { | |
| return await this.loadingPromise; | |
| } finally { | |
| this.loadingPart = -1; | |
| this.loadingPromise = null; | |
| } | |
| } | |
| async read(position, length) { | |
| if (length === 0) { | |
| return new Uint8Array(0); | |
| } | |
| if (!Number.isSafeInteger(position) || !Number.isSafeInteger(length) || position < 0 || length < 0 || position + length > this.totalSize) { | |
| throw new Error("Archive read outside bounds."); | |
| } | |
| const output = new Uint8Array(length); | |
| let current = position; | |
| let remaining = length; | |
| let outputOffset = 0; | |
| while (remaining > 0) { | |
| const part = this.findPart(current); | |
| const buffer = await this.loadPart(part); | |
| const inside = current - this.offsets[part]; | |
| const available = buffer.length - inside; | |
| if (available <= 0) { | |
| throw new Error("Invalid split archive boundary."); | |
| } | |
| const amount = Math.min(remaining, available); | |
| output.set(buffer.subarray(inside, inside + amount), outputOffset); | |
| current += amount; | |
| outputOffset += amount; | |
| remaining -= amount; | |
| } | |
| return output; | |
| } | |
| async stream(position, length) { | |
| if (length === 0) { | |
| return new ReadableStream({ | |
| start(controller) { | |
| controller.close(); | |
| } | |
| }); | |
| } | |
| const reader = this; | |
| let current = position; | |
| let remaining = length; | |
| const CHUNK_SIZE = 8 * 1024 * 1024; | |
| return new ReadableStream({ | |
| async pull(controller) { | |
| if (remaining <= 0) { | |
| controller.close(); | |
| return; | |
| } | |
| const amount = Math.min(CHUNK_SIZE, remaining); | |
| try { | |
| const chunk = await reader.read(current, amount); | |
| current += amount; | |
| remaining -= amount; | |
| controller.enqueue(chunk); | |
| if (remaining <= 0) { | |
| controller.close(); | |
| } | |
| } catch (error) { | |
| controller.error(error); | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| /* ============================================================ | |
| ZIP HELPERS | |
| ============================================================ */ | |
| function u16(buffer, offset) { | |
| return (buffer[offset] | (buffer[offset + 1] << 8)) >>> 0; | |
| } | |
| function u32(buffer, offset) { | |
| return (buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)) >>> 0; | |
| } | |
| function u64(buffer, offset) { | |
| let value = 0n; | |
| for (let i = 7; i >= 0; i--) { | |
| value = (value << 8n) | BigInt(buffer[offset + i]); | |
| } | |
| return value; | |
| } | |
| function decodeUTF8(bytes) { | |
| return new TextDecoder("utf-8").decode(bytes); | |
| } | |
| function findSignature(buffer, signature) { | |
| for (let i = buffer.length - 4; i >= 0; i--) { | |
| if (u32(buffer, i) === signature) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| /* ============================================================ | |
| ZIP DIRECTORY | |
| ============================================================ */ | |
| async function readZipDirectory(zip) { | |
| status("Reading ZIP directory..."); | |
| setProgress(0.61, "Reading ZIP directory..."); | |
| setDownloadUI("Reading ZIP directory...", "Finding ZIP64 central directory..."); | |
| const tailSize = Math.min(zip.totalSize, 65557 + 22); | |
| const tail = await zip.read(zip.totalSize - tailSize, tailSize); | |
| const eocd = findSignature(tail, 0x06054b50); | |
| if (eocd < 0) { | |
| throw new Error("ZIP end-of-central-directory record was not found."); | |
| } | |
| let entries = u16(tail, eocd + 10); | |
| let directorySize = u32(tail, eocd + 12); | |
| let directoryOffset = u32(tail, eocd + 16); | |
| if (entries === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) { | |
| const locator = findSignature(tail.subarray(0, eocd), 0x07064b50); | |
| if (locator < 0) { | |
| throw new Error("ZIP64 archive detected but ZIP64 locator is missing."); | |
| } | |
| const zip64Offset = u64(tail, locator + 8); | |
| if (zip64Offset > BigInt(Number.MAX_SAFE_INTEGER)) { | |
| throw new Error("ZIP64 offset is too large."); | |
| } | |
| const record = await zip.read(Number(zip64Offset), 56); | |
| if (u32(record, 0) !== 0x06064b50) { | |
| throw new Error("Invalid ZIP64 end record."); | |
| } | |
| entries = Number(u64(record, 32)); | |
| directorySize = Number(u64(record, 40)); | |
| directoryOffset = Number(u64(record, 48)); | |
| } | |
| log("ZIP entries: " + entries); | |
| const directory = await zip.read(directoryOffset, directorySize); | |
| const result = []; | |
| let offset = 0; | |
| for (let i = 0; i < entries; i++) { | |
| if (u32(directory, offset) !== 0x02014b50) { | |
| throw new Error("Invalid central directory entry at " + i); | |
| } | |
| const flags = u16(directory, offset + 8); | |
| const method = u16(directory, offset + 10); | |
| let compressedSize = u32(directory, offset + 20); | |
| let uncompressedSize = u32(directory, offset + 24); | |
| const nameLength = u16(directory, offset + 28); | |
| const extraLength = u16(directory, offset + 30); | |
| const commentLength = u16(directory, offset + 32); | |
| let localOffset = u32(directory, offset + 42); | |
| const externalAttributes = u32(directory, offset + 38); | |
| const name = decodeUTF8(directory.subarray(offset + 46, offset + 46 + nameLength)); | |
| const extraStart = offset + 46 + nameLength; | |
| const extra = directory.subarray(extraStart, extraStart + extraLength); | |
| if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) { | |
| let p = 0; | |
| while (p + 4 <= extra.length) { | |
| const id = u16(extra, p); | |
| const size = u16(extra, p + 2); | |
| if (id === 0x0001) { | |
| let q = p + 4; | |
| if (uncompressedSize === 0xffffffff) { | |
| uncompressedSize = Number(u64(extra, q)); | |
| q += 8; | |
| } | |
| if (compressedSize === 0xffffffff) { | |
| compressedSize = Number(u64(extra, q)); | |
| q += 8; | |
| } | |
| if (localOffset === 0xffffffff) { | |
| localOffset = Number(u64(extra, q)); | |
| } | |
| break; | |
| } | |
| p += 4 + size; | |
| } | |
| } | |
| /* | |
| * DIRECTORY DETECTION MUST HAPPEN BEFORE | |
| * PATH NORMALIZATION. | |
| */ | |
| const directoryEntry = name.endsWith("/") || ( | |
| (externalAttributes & 0x10) !== 0); | |
| result.push({ | |
| name, | |
| flags, | |
| method, | |
| compressedSize, | |
| uncompressedSize, | |
| localOffset, | |
| externalAttributes, | |
| directory: directoryEntry | |
| }); | |
| offset += 46 + nameLength + extraLength + commentLength; | |
| } | |
| /* -------------------------------------------------------- | |
| Detect common archive wrapper | |
| -------------------------------------------------------- */ | |
| const topLevelCandidates = new Set(); | |
| for (const entry of result) { | |
| const normalized = normalizeZipPath(entry.name); | |
| if (!normalized) continue; | |
| const slash = normalized.indexOf("/"); | |
| if (slash > 0) { | |
| topLevelCandidates.add(normalized.slice(0, slash)); | |
| } else if (!entry.directory) { | |
| topLevelCandidates.add(""); | |
| } | |
| } | |
| const wrapper = topLevelCandidates.size === 1 && !topLevelCandidates.has("") ? [...topLevelCandidates][0] : ""; | |
| if (wrapper) { | |
| log("Archive root folder detected: " + wrapper); | |
| log("Game files will be extracted directly into /cod4."); | |
| } else { | |
| log("No archive root wrapper detected."); | |
| } | |
| for (const entry of result) { | |
| entry.archiveRoot = wrapper; | |
| } | |
| setProgress(0.65, "ZIP directory loaded."); | |
| setDownloadUI("ZIP directory loaded.", `${entries.toLocaleString()} entries found`); | |
| return result; | |
| } | |
| /* ============================================================ | |
| SAFE ZIP PATH | |
| ============================================================ */ | |
| function normalizeZipPath(name) { | |
| name = name.replaceAll("\\", "/"); | |
| while (name.startsWith("/")) { | |
| name = name.slice(1); | |
| } | |
| const parts = name.split("/"); | |
| const safe = []; | |
| for (const part of parts) { | |
| if (!part || part === ".") { | |
| continue; | |
| } | |
| if (part === "..") { | |
| throw new Error("Unsafe ZIP path: " + name); | |
| } | |
| safe.push(part); | |
| } | |
| return safe.join("/"); | |
| } | |
| /* ============================================================ | |
| GAME PATH | |
| ============================================================ */ | |
| function getGamePath(entry) { | |
| let path = normalizeZipPath(entry.name); | |
| const wrapper = entry.archiveRoot || ""; | |
| if (wrapper) { | |
| const prefix = wrapper + "/"; | |
| if (path === wrapper) { | |
| return ""; | |
| } | |
| if (path.startsWith(prefix)) { | |
| path = path.slice(prefix.length); | |
| } | |
| } | |
| return normalizeZipPath(path); | |
| } | |
| /* ============================================================ | |
| OPFS | |
| ============================================================ */ | |
| async function getOPFSRoot() { | |
| if (!navigator.storage || !navigator.storage.getDirectory) { | |
| throw new Error("This browser does not support Origin Private File System."); | |
| } | |
| return navigator.storage.getDirectory(); | |
| } | |
| async function getFreshCod4Directory() { | |
| const root = await getOPFSRoot(); | |
| let existingType = "none"; | |
| try { | |
| await root.getDirectoryHandle("cod4"); | |
| existingType = "directory"; | |
| } catch (error) { | |
| if (error?.name === "TypeMismatchError") { | |
| try { | |
| await root.getFileHandle("cod4"); | |
| existingType = "file"; | |
| } catch (fileError) { | |
| if (fileError?.name !== "NotFoundError") { | |
| throw fileError; | |
| } | |
| } | |
| } else if (error?.name !== "NotFoundError") { | |
| throw error; | |
| } | |
| } | |
| if (existingType === "directory") { | |
| log("Removing existing /cod4 directory..."); | |
| await root.removeEntry("cod4", { | |
| recursive: true | |
| }); | |
| } else if (existingType === "file") { | |
| log("Removing incorrect /cod4 file..."); | |
| await root.removeEntry("cod4"); | |
| } | |
| try { | |
| await root.getDirectoryHandle("cod4"); | |
| throw new Error("Could not replace the existing /cod4 directory."); | |
| } catch (error) { | |
| if (error?.name !== "NotFoundError") { | |
| throw error; | |
| } | |
| } | |
| const cod4 = await root.getDirectoryHandle("cod4", { | |
| create: true | |
| }); | |
| log("Created fresh /cod4 directory."); | |
| return cod4; | |
| } | |
| async function getOPFSParent(root, path) { | |
| const parts = path.split("/"); | |
| const filename = parts.pop(); | |
| let directory = root; | |
| let currentPath = ""; | |
| for (const part of parts) { | |
| if (!part) continue; | |
| currentPath += (currentPath ? "/" : "") + part; | |
| try { | |
| directory = await directory.getDirectoryHandle(part); | |
| } catch (error) { | |
| if (error?.name === "NotFoundError") { | |
| directory = await directory.getDirectoryHandle(part, { | |
| create: true | |
| }); | |
| continue; | |
| } | |
| if (error?.name === "TypeMismatchError") { | |
| throw new Error(`ZIP path conflict: "${currentPath}" is a file, but the ZIP needs it to be a directory.`); | |
| } | |
| throw error; | |
| } | |
| } | |
| return { | |
| directory, | |
| filename | |
| }; | |
| } | |
| async function writeStreamToOPFS(root, path, stream) { | |
| const parent = await getOPFSParent(root, path); | |
| let file; | |
| try { | |
| file = await parent.directory.getFileHandle(parent.filename, { | |
| create: true | |
| }); | |
| } catch (error) { | |
| if (error?.name === "TypeMismatchError") { | |
| throw new Error(`ZIP path conflict: "${path}" already exists as a directory, but the ZIP needs it to be a file.`); | |
| } | |
| throw error; | |
| } | |
| const writable = await file.createWritable(); | |
| try { | |
| await stream.pipeTo(writable); | |
| } catch (error) { | |
| try { | |
| await writable.abort(); | |
| } catch {} | |
| throw error; | |
| } | |
| } | |
| async function ensureOPFSDirectory(root, path) { | |
| const normalized = normalizeZipPath(path); | |
| if (!normalized) return root; | |
| let directory = root; | |
| for (const part of normalized.split("/")) { | |
| try { | |
| directory = await directory.getDirectoryHandle(part); | |
| } catch (error) { | |
| if (error?.name === "NotFoundError") { | |
| directory = await directory.getDirectoryHandle(part, { | |
| create: true | |
| }); | |
| continue; | |
| } | |
| if (error?.name === "TypeMismatchError") { | |
| throw new Error(`ZIP path conflict: "${normalized}" contains a file where a directory is required.`); | |
| } | |
| throw error; | |
| } | |
| } | |
| return directory; | |
| } | |
| async function hasInstalledGame() { | |
| try { | |
| const root = await getOPFSRoot(); | |
| const cod4 = await root.getDirectoryHandle("cod4"); | |
| await cod4.getFileHandle("localization.txt"); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| /* ============================================================ | |
| FILE FILTER | |
| ============================================================ */ | |
| function shouldExtract(entry) { | |
| if (entry.directory) return false; | |
| const path = getGamePath(entry); | |
| if (!path) return false; | |
| if (path.startsWith("__MACOSX/") || path.endsWith(".DS_Store")) { | |
| return false; | |
| } | |
| const lower = path.toLowerCase(); | |
| if (!includeAudio.checked && (lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".ogg") || lower.endsWith(".flac") || lower.endsWith(".aac") || lower.endsWith(".m4a"))) { | |
| return false; | |
| } | |
| if (!includeVideo.checked && (lower.endsWith(".bik") || lower.endsWith(".roq") || lower.endsWith(".mp4") || lower.endsWith(".avi"))) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /* ============================================================ | |
| ZIP LOCAL HEADER | |
| ============================================================ */ | |
| async function getCompressedDataPosition(zip, entry) { | |
| const header = await zip.read(entry.localOffset, 30); | |
| if (u32(header, 0) !== 0x04034b50) { | |
| throw new Error("Invalid local ZIP header for " + entry.name); | |
| } | |
| const nameLength = u16(header, 26); | |
| const extraLength = u16(header, 28); | |
| return (entry.localOffset + 30 + nameLength + extraLength); | |
| } | |
| /* ============================================================ | |
| EXTRACTION | |
| ============================================================ */ | |
| async function extractEntry(zip, root, entry) { | |
| const path = getGamePath(entry); | |
| if (!path) return; | |
| try { | |
| if (entry.directory) { | |
| await ensureOPFSDirectory(root, path); | |
| return; | |
| } | |
| const slash = path.lastIndexOf("/"); | |
| if (slash >= 0) { | |
| await ensureOPFSDirectory(root, path.slice(0, slash)); | |
| } | |
| const dataPosition = await getCompressedDataPosition(zip, entry); | |
| const compressed = await zip.stream(dataPosition, entry.compressedSize); | |
| let outputStream; | |
| if (entry.method === 0) { | |
| outputStream = compressed; | |
| } else if (entry.method === 8) { | |
| if (!("DecompressionStream" in window)) { | |
| throw new Error("This browser does not support DecompressionStream."); | |
| } | |
| outputStream = compressed.pipeThrough(new DecompressionStream("deflate-raw")); | |
| } else { | |
| throw new Error(`Unsupported ZIP compression method ${entry.method} for ${path}`); | |
| } | |
| await writeStreamToOPFS(root, path, outputStream); | |
| } catch (error) { | |
| throw new Error(`Failed extracting "${path}": ${ | |
| error?.message || | |
| error | |
| }`); | |
| } | |
| } | |
| /* ============================================================ | |
| INSTALL | |
| ============================================================ */ | |
| let installing = false; | |
| async function installGame() { | |
| if (installing) return; | |
| installing = true; | |
| installButton.disabled = true; | |
| playButton.disabled = true; | |
| wipeButton.disabled = true; | |
| installOptions.hidden = false; | |
| installLog.textContent = ""; | |
| setProgress(0, "Preparing..."); | |
| setDownloadUI("Preparing COD4 installation...", ""); | |
| try { | |
| log("Preparing split ZIP..."); | |
| log(`Archive parts: ${PART_COUNT}`); | |
| // Fail fast (with a clear message) if the upload is | |
| // missing parts, rather than 404-ing mid-install. | |
| await assertArchiveComplete(); | |
| const zip = new SplitZipReader(PART_COUNT, PART_PREFIX); | |
| await zip.initialize(); | |
| status("Reading ZIP directory..."); | |
| const entries = await readZipDirectory(zip); | |
| const files = entries.filter(shouldExtract); | |
| if (!files.length) { | |
| throw new Error("No usable files were found in the ZIP."); | |
| } | |
| let totalOutput = 0; | |
| for (const entry of files) { | |
| totalOutput += entry.uncompressedSize; | |
| } | |
| installCopySize.textContent = files.length.toLocaleString() + " files selected • " + formatBytes(totalOutput) + " extracted size"; | |
| log("Files selected: " + files.length.toLocaleString()); | |
| log("Extracted size: " + formatBytes(totalOutput)); | |
| log("Clearing old game data..."); | |
| const root = await getFreshCod4Directory(); | |
| let completedBytes = 0; | |
| let completedFiles = 0; | |
| setDownloadUI("Installing game files...", ""); | |
| for (const entry of files) { | |
| const path = getGamePath(entry); | |
| status("Extracting " + path); | |
| setDownloadUI(`Extracting ${completedFiles + 1} / ${files.length}`, path); | |
| await extractEntry(zip, root, entry); | |
| completedBytes += entry.uncompressedSize; | |
| completedFiles++; | |
| const extractionProgress = totalOutput > 0 ? completedBytes / totalOutput : completedFiles / files.length; | |
| const displayed = 0.65 + extractionProgress * 0.35; | |
| setProgress(displayed, `Extracting ${completedFiles.toLocaleString()} / ${files.length.toLocaleString()} — ${formatBytes(completedBytes)} / ${formatBytes(totalOutput)}`); | |
| if (completedFiles % 16 === 0) { | |
| await new Promise(resolve => setTimeout(resolve, 0)); | |
| } | |
| } | |
| await root.getFileHandle("localization.txt"); | |
| localStorage.setItem("kisak_opfs_ready", "1"); | |
| window.__kisakOpfsReady = true; | |
| if (window.__kisakOpfsDependencyHeld && typeof removeRunDependency === "function") { | |
| window.__kisakOpfsDependencyHeld = false; | |
| removeRunDependency("kisak-opfs"); | |
| } | |
| setProgress(1, "Installation complete."); | |
| setDownloadUI("COD4 installation complete.", `${files.length.toLocaleString()} files extracted • ${formatBytes(totalOutput)}`); | |
| status("Game installed successfully."); | |
| log(""); | |
| log("Installation complete."); | |
| playButton.disabled = false; | |
| } catch (error) { | |
| // An incomplete upload is an expected, handled state, | |
| // not an unexpected crash: warn rather than error. | |
| if (error?.incompleteArchive) { | |
| console.warn(error.message); | |
| } else { | |
| console.error(error); | |
| } | |
| status(error?.incompleteArchive ? "Game data incomplete." : "Installation failed."); | |
| log(""); | |
| log(error?.incompleteArchive ? error.message : ("ERROR: " + (error?.stack || error?.message || error))); | |
| setDownloadUI(error?.incompleteArchive ? "Game data incomplete" : "Installation failed.", error?.message || String(error)); | |
| installButton.disabled = false; | |
| } finally { | |
| wipeButton.disabled = false; | |
| installing = false; | |
| } | |
| } | |
| /* ============================================================ | |
| WIPE DATA | |
| ============================================================ */ | |
| async function wipeGame() { | |
| if (!confirm("Delete all locally stored KisakCOD game data?")) { | |
| return; | |
| } | |
| try { | |
| const root = await getOPFSRoot(); | |
| try { | |
| await root.removeEntry("cod4", { | |
| recursive: true | |
| }); | |
| } catch (error) { | |
| if (error?.name !== "NotFoundError") { | |
| if (error?.name === "TypeMismatchError") { | |
| await root.removeEntry("cod4"); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| localStorage.removeItem("kisak_opfs_ready"); | |
| window.__kisakOpfsReady = false; | |
| playButton.disabled = true; | |
| status("Game data deleted."); | |
| log("OPFS /cod4 deleted."); | |
| } catch (error) { | |
| console.error(error); | |
| status("Could not delete game data."); | |
| log("ERROR: " + (error?.message || error)); | |
| } | |
| } | |
| /* ============================================================ | |
| PLAY | |
| ============================================================ */ | |
| function playGame() { | |
| if (!window.__kisakOpfsReady) { | |
| status("The game files are not installed yet."); | |
| return; | |
| } | |
| installerPanel.style.display = "none"; | |
| document.body.classList.add("game-running"); | |
| resizeGameCanvas(); | |
| try { | |
| canvas.focus({ | |
| preventScroll: true | |
| }); | |
| } catch { | |
| try { | |
| canvas.focus(); | |
| } catch {} | |
| } | |
| } | |
| /* ============================================================ | |
| INITIAL STATE | |
| ============================================================ */ | |
| async function initializeInstaller() { | |
| try { | |
| const installed = await hasInstalledGame(); | |
| if (installed) { | |
| window.__kisakOpfsReady = true; | |
| localStorage.setItem("kisak_opfs_ready", "1"); | |
| status("Game data is already installed."); | |
| playButton.disabled = false; | |
| log("Existing /cod4 installation found."); | |
| } else { | |
| window.__kisakOpfsReady = false; | |
| status("Game files are not installed."); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| status("Unable to inspect browser storage."); | |
| log("ERROR: " + (error?.message || error)); | |
| } | |
| } | |
| /* ============================================================ | |
| FORMAT BYTES | |
| ============================================================ */ | |
| function formatBytes(bytes) { | |
| if (!Number.isFinite(bytes)) { | |
| return "unknown size"; | |
| } | |
| if (bytes < 1024) { | |
| return bytes + " B"; | |
| } | |
| if (bytes < 1024 ** 2) { | |
| return (bytes / 1024).toFixed(1) + " KiB"; | |
| } | |
| if (bytes < 1024 ** 3) { | |
| return (bytes / 1024 ** 2).toFixed(1) + " MiB"; | |
| } | |
| return (bytes / 1024 ** 3).toFixed(2) + " GiB"; | |
| } | |
| /* ============================================================ | |
| EVENTS | |
| ============================================================ */ | |
| installButton.addEventListener("click", installGame); | |
| playButton.addEventListener("click", playGame); | |
| wipeButton.addEventListener("click", wipeGame); | |
| /* ============================================================ | |
| START | |
| ============================================================ */ | |
| initializeInstaller(); | |
| /* ============================================================ | |
| LOAD KISAKCOD SCRIPT | |
| ============================================================ */ | |
| const gameScript = document.getElementById("module-script-template").content.querySelector("script"); | |
| document.body.appendChild(gameScript); | |
| </script> | |
| </body> | |
| </html> |
Xet Storage Details
- Size:
- 55.6 kB
- Xet hash:
- a5a14db4cde8d7dd9e43e8b4b10575be022321d5eb627e6e6cbe86a1e9f6ff8c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.