Initial commit: DeathWatch, an Elden Ring death counter for OBS

Reads the game's process memory read-only (AOB signature scanning,
re-resolved every tick — never a cached pointer) and serves a live
death counter as a browser-source overlay plus a status panel.

Supports co-op: each player runs the program locally, one acts as
hub. The peer link is authenticated (HMAC challenge/response, no
token on the wire, constant-time compare) but not yet encrypted —
see CLAUDE.md's TODO section for the planned TLS+pinning split.

Zero external dependencies — including a hand-rolled WebSocket
implementation — so the whole thing ships as one .exe.
This commit is contained in:
emmatherock committed 2026-09-17 21:10:22 -03:00
commit e1f7e6f529
18 files changed
+3690

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Elden Ring Death Counter</title>
<style>
:root{
--bg:#0b0906;
--bg-elevated:#17130f;
--bg-card:#1d1712;
--gold:#c9a24b;
--gold-bright:#e8c874;
--ash-red:#8a2a2a;
--ash-red-bright:#b23a3a;
--parchment:#e9e2cf;
--muted:#948a76;
--rule:#3a3226;
--ok:#7fae6e;
--shadow:0 20px 60px rgba(0,0,0,.55);
}
*{box-sizing:border-box;}
html,body{background:var(--bg);}
body{
margin:0; min-height:100vh; background:var(--bg); color:var(--parchment);
font-family:'EB Garamond', Georgia, 'Times New Roman', serif;
-webkit-font-smoothing:antialiased;
}
[hidden]{display:none!important;}
.display,h1,.num{font-family:'Cinzel', Georgia, serif;}
.eyebrow{font-family:'Cinzel', Georgia, serif;font-size:.72rem;letter-spacing:.32em;text-transform:uppercase;color:var(--muted);}
.num{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}
@media (prefers-reduced-motion: reduce){*{animation-duration:.001ms!important;transition-duration:.001ms!important;}}
/* overlay (OBS) */
html.mode-overlay,html.mode-overlay body{background:transparent;}
#overlay{background:transparent;min-height:100vh;display:flex;align-items:flex-start;justify-content:flex-start;padding:16px;}
.plate{
background:rgba(9,7,5,.9); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
border-top:1px solid var(--gold); border-bottom:1px solid var(--gold);
padding:18px 24px; width:600px; box-sizing:border-box; box-shadow:var(--shadow);
}
/* Con un solo jugador la placa se angosta; el canvas de OBS no cambia,
simplemente ocupa menos. Asi las dimensiones se configuran una vez. */
.plate.solo{width:420px;}
.players{display:flex;align-items:stretch;justify-content:center;}
.pcol{flex:1 1 0;min-width:0;text-align:center;}
.pname{
font-family:'Cinzel', Georgia, serif;font-size:.78rem;letter-spacing:.24em;text-transform:uppercase;
color:var(--gold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.pnum{
display:block;font-family:'Cinzel', Georgia, serif;font-variant-numeric:tabular-nums;
font-size:3.6rem;line-height:1.05;font-weight:600;color:var(--parchment);margin-top:.25rem;
transition:color .15s ease, transform .15s ease;
}
.pnum.pulse{color:var(--gold-bright);transform:scale(1.06);}
/* Una sola linea bajo el numero: dice "muertes" y, cuando hay algo que
contar (jefe, sin conexion), lo agrega ahi mismo. Asi la palabra
siempre esta y el alto del overlay no cambia nunca. */
.pnote{
font-family:'Cinzel', Georgia, serif;font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--muted);min-height:1.5em;margin-top:.35rem;
}
.pnote .st{color:var(--ash-red-bright);font-style:italic;}
.pnote .sep{opacity:.5;margin:0 .45em;}
.pcol.off .pname,.pcol.off .pnum{color:var(--muted);}
.pdiv{width:1px;flex:0 0 1px;margin:0 20px;background:linear-gradient(180deg,transparent,var(--gold),transparent);opacity:.75;}
/* control / status */
#control{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:3rem 1.25rem;}
.card{width:100%;max-width:560px;background:var(--bg-card);border:1px solid var(--rule);box-shadow:var(--shadow);padding:2.4rem 2.4rem 2rem;position:relative;}
.card::before,.card::after{content:"";position:absolute;left:2.4rem;right:2.4rem;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);opacity:.6;}
.card::before{top:1.35rem;} .card::after{bottom:1.35rem;}
.title{text-align:center;font-size:1.05rem;letter-spacing:.28em;text-transform:uppercase;color:var(--gold);margin:0 0 .3rem;}
.title small{display:block;font-family:'EB Garamond', Georgia, serif;letter-spacing:0;text-transform:none;font-style:italic;color:var(--muted);font-size:.95rem;margin-top:.3rem;}
.hero{text-align:center;margin:2.1rem 0 1.6rem;}
.hero .num{font-size:6rem;line-height:1;font-weight:700;color:var(--parchment);transition:color .15s ease, transform .15s ease;}
.hero .num.pulse{color:var(--gold-bright);transform:scale(1.05);}
.hero .eyebrow{margin-top:.5rem;}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--rule);margin:1.6rem 0;border:1px solid var(--rule);}
.stat{background:var(--bg-elevated);padding:.9rem 1rem;text-align:center;}
.stat .stat-num{font-size:1.5rem;font-weight:600;color:var(--gold);}
.stat .stat-label{font-size:.65rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-top:.2rem;}
.field{margin:1.4rem 0;}
.field label{display:block;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:.4rem;}
.field .row{display:flex;gap:.5rem;}
.field input[type=number],.field input[type=text]{
width:100%;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--parchment);
font-family:'EB Garamond', Georgia, serif;font-size:1.05rem;padding:.55rem .75rem;
}
.field input:focus{border-color:var(--gold);outline:none;}
.btn{border:1px solid var(--gold);background:transparent;color:var(--gold-bright);padding:.6rem 1rem;font-size:.85rem;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .15s ease;}
.btn:hover{background:rgba(201,162,75,.12);}
.hint{font-size:.78rem;color:var(--muted);margin-top:.5rem;line-height:1.5;}
.obs-box{margin-top:1.8rem;padding-top:1.3rem;border-top:1px solid var(--rule);}
.obs-url-row{display:flex;gap:.5rem;}
.obs-url-row input{flex:1;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--muted);font-family:'EB Garamond', monospace;font-size:.85rem;padding:.5rem .6rem;}
.copy-btn{border:1px solid var(--rule);background:transparent;color:var(--muted);padding:.5rem .9rem;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;}
.copy-btn:hover{border-color:var(--gold);color:var(--gold);}
.status{text-align:center;font-size:.75rem;color:var(--muted);margin-top:1.3rem;letter-spacing:.05em;}
.status .dot{display:inline-block;width:.45em;height:.45em;border-radius:50%;background:var(--muted);margin-right:.4em;}
.status.live .dot{background:var(--ok);}
.status.err .dot{background:var(--ash-red-bright);}
.build{text-align:center;font-size:.68rem;color:var(--muted);margin-top:.4rem;letter-spacing:.04em;opacity:.7;}
.boss-flag{display:none;text-align:center;color:var(--ash-red-bright);font-style:italic;letter-spacing:.08em;margin-top:.3rem;}
.boss-flag.on{display:block;}
.peer-row{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;padding:.45rem .75rem;background:var(--bg-elevated);border:1px solid var(--rule);margin-bottom:.35rem;}
.peer-row .pn{color:var(--parchment);}
.peer-row .pv{font-family:'Cinzel', Georgia, serif;color:var(--gold);font-variant-numeric:tabular-nums;}
.peer-row.off .pn,.peer-row.off .pv{color:var(--muted);}
.peer-row .ps{font-size:.72rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<script>
(function(){
var params = new URLSearchParams(location.search);
var mode = (params.get('view') || 'control').toLowerCase();
if (mode !== 'overlay') mode = 'control';
document.documentElement.classList.add('mode-' + mode);
window.__mode = mode;
})();
</script>
</head>
<body>
<div id="overlay" hidden>
<div class="plate solo" id="ov-plate">
<div class="players" id="ov-players"></div>
</div>
</div>
<div id="control" hidden>
<div class="card">
<p class="title"><span data-i18n="panel.title">Elden Ring</span><small data-i18n="panel.subtitle">Death Counter</small></p>
<div class="hero">
<span class="num" id="c-total">–</span>
<div class="eyebrow" data-i18n="panel.total">Total deaths</div>
</div>
<div class="boss-flag" id="c-boss" data-i18n="panel.boss">⚔ In a boss fight</div>
<div class="stats">
<div class="stat"><div class="stat-num" id="c-raw">–</div><div class="stat-label" data-i18n="panel.raw">Read from process</div></div>
<div class="stat"><div class="stat-num" id="c-slot">–</div><div class="stat-label" data-i18n="panel.slot">Save slot</div></div>
</div>
<div class="field" id="c-peers-box" hidden>
<label data-i18n="panel.partners">Partners</label>
<div id="c-peers"></div>
</div>
<div class="obs-box">
<div class="field">
<label data-i18n="panel.obsUrl">URL for OBS (Browser Source)</label>
<div class="obs-url-row">
<input type="text" id="obs-url" readonly>
<button class="copy-btn" id="copy-btn" data-i18n="panel.copy">Copy</button>
</div>
</div>
<p class="hint" data-i18n="panel.obsHint"></p>
</div>
<p class="status" id="status"><span class="dot"></span><span id="status-text" data-i18n="status.connecting">Connecting…</span></p>
<p class="build" id="c-build"></p>
</div>
</div>
<script>
(function(){
var mode = window.__mode || 'control';
// Textos de la interfaz. El diccionario real viene de /strings.json
// (ver i18n.go y locales/*.json). Este de aca es el bote salvavidas:
// si esa peticion falla, se ve ingles y nunca una clave cruda.
var FALLBACK = {
"overlay.deaths": "deaths",
"overlay.boss": "boss",
"overlay.offline": "no connection",
"overlay.waiting": "waiting for the game…",
"overlay.you": "You",
"overlay.partner": "Partner",
"panel.title": "Elden Ring",
"panel.subtitle": "Death Counter · live reading",
"panel.total": "Total deaths",
"panel.raw": "Read from process",
"panel.slot": "Save slot",
"panel.boss": "⚔ In a boss fight",
"panel.partners": "Partners",
"panel.obsUrl": "URL for OBS (Browser Source)",
"panel.copy": "Copy",
"panel.copied": "Copied",
"panel.copyManual": "Select and copy",
"panel.obsHint": "Paste this URL into a Browser source in OBS. Transparent background, updates on its own as long as this console window stays open.",
"panel.build": "Build",
"status.connecting": "Connecting…",
"status.waitingGame": "Waiting for you to open Elden Ring…",
"status.menu": "Elden Ring open — main menu or loading screen",
"status.live": "Live — reading Elden Ring",
"status.noServer": "No connection to the local program",
"peer.playing": "playing",
"peer.menu": "in the menu",
"peer.offline": "no connection"
};
var STR = {};
function t(k){ return STR[k] || FALLBACK[k] || k; }
function applyStrings(){
var nodes = document.querySelectorAll('[data-i18n]');
for (var i = 0; i < nodes.length; i++){
nodes[i].textContent = t(nodes[i].getAttribute('data-i18n'));
}
document.documentElement.lang = STR['_lang'] || 'en';
}
document.getElementById(mode === 'overlay' ? 'overlay' : 'control').hidden = false;
function fmtElapsed(ms){
if (ms == null) return '—';
var s = Math.floor(ms/1000);
var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
function pad(n){return (n<10?'0':'')+n;}
return h>0 ? (h+':'+pad(m)+':'+pad(ss)) : (pad(m)+':'+pad(ss));
}
var prevTotal = null;
function pulse(el){ el.classList.add('pulse'); setTimeout(function(){el.classList.remove('pulse');},260); }
if (mode === 'control'){
document.getElementById('obs-url').value = location.origin + '/?view=overlay';
document.getElementById('copy-btn').addEventListener('click', function(){
var btn=this, url=document.getElementById('obs-url');
function done(ok){ btn.textContent = ok?t('panel.copied'):t('panel.copyManual'); setTimeout(function(){btn.textContent=t('panel.copy');},1400); }
if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(url.value).then(function(){done(true);}, function(){url.select();done(false);});
} else { url.select(); done(false); }
});
}
function setStatus(text, cls){
var el = document.getElementById('status');
if (!el) return;
document.getElementById('status-text').textContent = text;
el.className = 'status' + (cls ? ' ' + cls : '');
}
// Cache de las columnas: reconstruir el DOM en cada tick haria
// parpadear el overlay y perderia la animacion del pulso.
//
// Se indexan por POSICION, nunca por nombre. Indexarlas por nombre fue
// un bug real: al cambiar de personaje cambiaba la clave, se creaba una
// columna nueva y la vieja quedaba colgada en el DOM, asi que un mismo
// jugador aparecia dos veces. El nombre es contenido, no identidad.
var slots = [];
var prevByName = {};
// ensureSlots deja exactamente n columnas, creando o sacando las que
// hagan falta (con su divisor).
function ensureSlots(list, n){
while (slots.length > n){
var old = slots.pop();
if (old.div) list.removeChild(old.div);
list.removeChild(old.col);
}
while (slots.length < n){
var i = slots.length;
var div = null;
if (i > 0){
div = document.createElement('div');
div.className = 'pdiv';
list.appendChild(div);
}
var col = document.createElement('div'); col.className = 'pcol';
var nm = document.createElement('div'); nm.className = 'pname';
var vl = document.createElement('span'); vl.className = 'pnum';
var nt = document.createElement('div'); nt.className = 'pnote';
col.appendChild(nm); col.appendChild(vl); col.appendChild(nt);
list.appendChild(col);
slots.push({col:col, div:div, name:nm, num:vl, note:nt});
}
}
function playersOf(d){
if (d.players && d.players.length) return d.players;
// Respuesta de la version de un solo jugador.
return [{name:'Deaths', deaths:d.deaths, bossFight:d.bossFight,
playerLoaded:d.playerLoaded, connected:d.connected, self:true}];
}
function renderOverlay(d){
var players = playersOf(d);
var list = document.getElementById('ov-players');
document.getElementById('ov-plate').className = 'plate' + (players.length < 2 ? ' solo' : '');
ensureSlots(list, players.length);
for (var i = 0; i < players.length; i++){
var p = players[i];
var slot = slots[i];
// Sin nombre de personaje todavía: etiqueta según el modo.
slot.name.textContent = p.name ||
(players.length < 2 ? t('overlay.deaths') : (p.self ? t('overlay.you') : t('overlay.partner')));
slot.num.textContent = p.connected ? p.deaths : '–';
slot.col.className = 'pcol' + (p.connected ? '' : ' off');
slot.note.textContent = '';
if (!p.connected){
slot.note.textContent = p.self ? t('overlay.waiting') : t('overlay.offline');
} else {
slot.note.appendChild(document.createTextNode(t('overlay.deaths')));
if (p.bossFight){
var sep = document.createElement('span');
sep.className = 'sep'; sep.textContent = '·';
var st = document.createElement('span');
st.className = 'st'; st.textContent = '⚔ ' + t('overlay.boss');
slot.note.appendChild(sep); slot.note.appendChild(st);
}
}
var prev = prevByName[p.name];
if (p.connected && prev !== undefined && p.deaths > prev) pulse(slot.num);
prevByName[p.name] = p.connected ? p.deaths : undefined;
}
}
function render(d){
var total = d.deaths;
var display = d.connected ? total : '–';
if (mode === 'overlay'){
renderOverlay(d);
} else {
var cTotal = document.getElementById('c-total');
cTotal.textContent = display;
document.getElementById('c-raw').textContent = d.connected ? d.rawDeaths : '–';
document.getElementById('c-slot').textContent = (d.slot === undefined || d.slot < 0) ? '–' : d.slot;
document.getElementById('c-boss').classList.toggle('on', !!d.bossFight);
document.getElementById('c-build').textContent = d.build ? (t('panel.build') + ': ' + d.build) : '';
if (d.connected && prevTotal !== null && total > prevTotal) pulse(cTotal);
if (!d.connected) setStatus(t('status.waitingGame'), '');
else if (d.playerLoaded === false) setStatus(t('status.menu'), '');
else setStatus(t('status.live'), 'live');
var peers = (d.players || []).filter(function(p){ return !p.self; });
document.getElementById('c-peers-box').hidden = peers.length === 0;
var box = document.getElementById('c-peers');
box.textContent = '';
peers.forEach(function(p){
var row = document.createElement('div');
row.className = 'peer-row' + (p.connected ? '' : ' off');
var n = document.createElement('span'); n.className = 'pn'; n.textContent = p.name;
var s = document.createElement('span'); s.className = 'ps';
s.textContent = p.connected ? (p.playerLoaded ? t('peer.playing') : t('peer.menu')) : t('peer.offline');
var v = document.createElement('span'); v.className = 'pv';
v.textContent = p.connected ? p.deaths : '–';
var left = document.createElement('span');
left.appendChild(n); left.appendChild(document.createTextNode(' ')); left.appendChild(s);
row.appendChild(left); row.appendChild(v);
box.appendChild(row);
});
}
prevTotal = d.connected ? total : null;
}
function poll(){
fetch('/deaths', {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
render(d);
}).catch(function(){
setStatus(t('status.noServer'), 'err');
}).finally(function(){
setTimeout(poll, 1000);
});
}
// ?lang= en la URL pisa el idioma del config, comodo para tener el
// overlay en un idioma y el panel en otro sin tocar archivos.
var langParam = new URLSearchParams(location.search).get('lang');
fetch('/strings.json' + (langParam ? '?lang=' + encodeURIComponent(langParam) : ''), {cache:'no-store'})
.then(function(r){ return r.json(); })
.then(function(d){ if (d && typeof d === 'object') STR = d; })
.catch(function(){ /* nos quedamos con FALLBACK */ })
.finally(function(){ applyStrings(); poll(); });
})();
</script>
</body>
</html>