Organize scripts and add TWS plugins with HTTP timeouts

Vendor pinned T5 and T6 plugin sources under the new plugins layout. Add native HTTP deadlines, status and timing diagnostics, telemetry integration, and build/test tooling.
This commit is contained in:
2026-10-09 11:02:20 -04:00
parent b172086301
commit 1121b44857
253 changed files with 28028 additions and 59 deletions
+26
View File
@@ -0,0 +1,26 @@
param(
[ValidateSet('t5', 't6', 'all')]
[string]$Game = 'all',
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$games = if ($Game -eq 'all') { @('t5', 't6') } else { @($Game) }
foreach ($current in $games) {
& python (Join-Path $PSScriptRoot 'fetch_plugin_deps.py') $current
if ($LASTEXITCODE -ne 0) { throw "Dependency restore failed for $current" }
$plugin = Join-Path $repo "$current/plugins/tws-$current-gsc-utils"
Push-Location $plugin
try {
& .\tools\premake5.exe vs2022
if ($LASTEXITCODE -ne 0) { throw "Project generation failed for $current" }
& msbuild /m /v:minimal "/p:Configuration=$Configuration" /p:PlatformTarget=x86 "build/tws-$current-gsc-utils.sln"
if ($LASTEXITCODE -ne 0) { throw "Build failed for $current" }
$binary = Join-Path $plugin "build/bin/$Configuration/tws-$current-gsc-utils.dll"
if (!(Test-Path -LiteralPath $binary)) { throw "Missing output: $binary" }
Write-Output "Built $binary"
} finally {
Pop-Location
}
}
+4 -4
View File
@@ -42,7 +42,7 @@ function functions(source) {
}
function engine(game) {
const source = readFileSync(new URL(`${game}/tws_${game}_leaderboard.gsc`, root), 'utf8');
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy', 'lb_chat_listener', 'lb_request', 'lb_hud_disconnect']);
const table = [];
@@ -90,7 +90,7 @@ for (const game of ['t5', 't6']) {
const { context: c, source, all, dvars, responses, queries } = engine(game);
const option = mapOption(game);
check(`${game} exactly one connect listener across modules`, () => {
const common = readFileSync(new URL(`${game}/tws_${game}_common.gsc`, root), 'utf8');
const common = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
assert.equal((common.match(/waittill\("connected"/g) ?? []).length, 1);
assert(!source.includes('waittill("connected"'));
assert(!all.get('lb_welcome').includes('spawned_player'));
@@ -227,8 +227,8 @@ for (const game of ['t5', 't6']) {
assert.equal(responses.length, 0, `${game} unused responses`);
}
check('paired data helpers stay identical', () => {
const t5 = functions(readFileSync(new URL('t5/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/tws_t6_leaderboard.gsc', root), 'utf8'));
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
for (const [name, fn] of t5) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Restore the exact upstream dependency revisions for the vendored plugins."""
import argparse
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def git(directory, *args, capture=False):
return subprocess.check_output(["git", "-C", str(directory), *args], text=True).strip() if capture else subprocess.check_call(["git", "-C", str(directory), *args])
def restore(game, check_only):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
lock = json.loads((plugin / "upstream.json").read_text())
for dependency in lock["dependencies"]:
target = plugin / dependency["path"]
if not target.resolve().is_relative_to(plugin.resolve()):
raise RuntimeError(f"Dependency path leaves the plugin: {target}")
if not (target / ".git").exists():
if check_only:
raise RuntimeError(f"Missing dependency: {target}")
if target.exists() and any(target.iterdir()):
raise RuntimeError(f"Refusing to replace nonempty directory: {target}")
target.mkdir(parents=True, exist_ok=True)
git(target, "init", "--quiet")
git(target, "remote", "add", "origin", dependency["url"])
if git(target, "remote", "get-url", "origin", capture=True) != dependency["url"]:
raise RuntimeError(f"Unexpected dependency remote: {target}")
if git(target, "status", "--porcelain", capture=True):
raise RuntimeError(f"Dependency has local changes: {target}")
current = subprocess.run(["git", "-C", str(target), "rev-parse", "--verify", "HEAD"], capture_output=True, text=True)
if current.stdout.strip() != dependency["commit"]:
if check_only:
raise RuntimeError(f"Wrong dependency revision: {target}")
git(target, "fetch", "--depth", "1", "origin", dependency["commit"])
git(target, "checkout", "--quiet", "--detach", dependency["commit"])
print(f"OK {game}/{dependency['path']} {dependency['commit']}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("game", choices=["t5", "t6", "all"])
parser.add_argument("--check", action="store_true", help="Verify existing checkouts without fetching or changing them")
args = parser.parse_args()
for game in ("t5", "t6") if args.game == "all" else (args.game,):
restore(game, args.check)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Compile actual HTTP component logic using host shims for engine interfaces."""
import os
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix="tws-http-component-") as temp:
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils" / "src"
# Only includes are substituted; compile the entire component unchanged.
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "component/http.cpp").read_text(), flags=re.MULTILINE)
source = Path(temp) / f"{game}.cpp"
source.write_text('#include "http_component_stub.hpp"\n' + component + (ROOT / "tools/tests/http_component_cases.cpp").read_text())
executable = Path(temp) / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
"-I", str(plugin / "utils"), "-I", str(ROOT / "tools/tests"), str(source), "-lcurl", "-o", str(executable)], check=True)
print(game, flush=True)
subprocess.run([str(executable)], check=True)
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Compile both real HTTP utilities and exercise native deadlines on loopback."""
import http.server
import json
import os
from pathlib import Path
import socketserver
import subprocess
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parents[1]
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
assert body == b'{"event":"heartbeat"}'
assert self.headers.get("Content-Type") == "application/json"
assert self.headers.get("Authorization") == "Bearer test-only"
if self.path == "/stall":
time.sleep(2)
return
if self.path == "/partial":
self.send_response(200)
self.send_header("Content-Length", "100")
self.end_headers()
self.wfile.write(b"{")
self.wfile.flush()
time.sleep(2)
return
if self.path == "/slow":
time.sleep(0.1)
status = int(self.path[1:]) if self.path[1:].isdigit() else 200
payload = b'{"ok":true}' if status == 200 else b'{"ok":false}'
self.send_response(status)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
class StallTLS(socketserver.BaseRequestHandler):
def handle(self):
# Accept TCP but never finish TLS: the connection deadline must fire.
time.sleep(6)
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
def main():
with Server(("127.0.0.1", 0), Handler) as http, Server(("127.0.0.1", 0), StallTLS) as tls, tempfile.TemporaryDirectory(prefix="tws-http-tests-") as temp:
for server in (http, tls):
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
for game in ("t5", "t6"):
source = ROOT / game / "plugins" / f"tws-{game}-gsc-utils" / "src" / "utils"
executable = Path(temp) / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-I", str(source),
str(ROOT / "tools/tests/http_probe.cpp"), str(source / "http.cpp"), "-lcurl", "-o", str(executable)], check=True)
base = f"http://127.0.0.1:{http.server_address[1]}"
env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"}
def probe(path, connect="1000", total="1000"):
url = path if "://" in path else base + path
completed = subprocess.run([str(executable), url, connect, total], env=env, capture_output=True, text=True, check=True, timeout=8)
return json.loads(completed.stdout)
normal = probe("/200", "-", "-")
assert normal["curl_code"] == 0 and normal["http_status"] == 200 and normal["body_size"] == 11, normal
assert 0 <= normal["dns_ms"] <= normal["connect_ms"] <= normal["first_byte_ms"] <= normal["total_ms"], normal
for status in (401, 429, 500):
rejected = probe(f"/{status}")
assert rejected["curl_code"] == 0 and rejected["http_status"] == status and rejected["body_size"] == 12, rejected
for path, status in (("/stall", 0), ("/partial", 200)):
timed_out = probe(path, total="200")
assert timed_out["curl_code"] == 28 and timed_out["http_status"] == status, timed_out
assert 150 <= timed_out["total_ms"] < 1500 and timed_out["wall_ms"] < 1500, timed_out
assert timed_out["body_size"] == 0, timed_out
if status: assert timed_out["first_byte_ms"] > 0, timed_out
assert probe("/200")["curl_code"] == 0 # The next request completes after the stall.
slow = probe("/slow")
assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow
tls_url = f"https://127.0.0.1:{tls.server_address[1]}/"
stalled_connect = probe(tls_url, connect="150", total="1000")
assert stalled_connect["curl_code"] == 28 and 100 <= stalled_connect["total_ms"] < 800, stalled_connect
# Total timeout must also bound connection setup when it is shorter.
total_connect = probe(tls_url, connect="1000", total="150")
assert total_connect["curl_code"] == 28 and 100 <= total_connect["total_ms"] < 800, total_connect
for connect, total in (("0", "100"), ("100", "0"), ("-1", "100")):
invalid = probe("/200", connect, total)
assert invalid["curl_code"] == 43 and invalid["http_status"] == 0, invalid
print(f"PASS {game}: POST body/headers, HTTP errors, stalled/partial responses, recovery, TLS/connect and total deadlines, invalid limits", flush=True)
finally:
http.shutdown()
tls.shutdown()
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
int main()
{
using namespace scripting;
http::http_request_params_t params{};
array options;
http::parse_request_options(params, options);
assert(params.limits.connect_timeout_ms == 5000 && params.limits.timeout_ms == 15000);
options["connect_timeout_ms"] = 120;
options["timeout_ms"] = 450;
options["method"] = "POST";
options["body"] = "snapshot";
array headers;
headers["Authorization"] = "Bearer test-only";
options["headers"] = headers;
http::parse_request_options(params, options);
assert(params.limits.connect_timeout_ms == 120 && params.limits.timeout_ms == 450);
assert(params.method == "POST" && params.fields == "snapshot" && params.headers.at("Authorization") == "Bearer test-only");
for (const auto* key : {"connect_timeout_ms", "timeout_ms"})
{
for (script_value value : {script_value(0), script_value(-1), script_value(1.5), script_value("150")})
{
array bad;
bad[key] = value;
bool rejected = false;
try { http::parse_request_options(params, bad); }
catch (const std::runtime_error&) { rejected = true; }
assert(rejected);
}
}
http::parse_request_options(params, options);
auto send = [&]()
{
notifications.clear();
http::create_request(params);
http::check_requests();
assert(notifications.empty()); // Incomplete work never notifies early.
scheduler::thread_pool.run();
http::check_requests();
assert(notifications.size() == 1 && notifications.front().size() == 4);
assert(utils::http::observed_limits.connect_timeout_ms == 120 && utils::http::observed_limits.timeout_ms == 450);
auto diagnostics = notifications.front()[3].as<array>();
assert(diagnostics["worker_queue_ms"].as<double>() >= 0 && diagnostics["notify_ms"].as<double>() >= 0);
assert(diagnostics["connect_timeout_ms"].as<int>() == 120 && diagnostics["timeout_ms"].as<int>() == 450);
assert(diagnostics.data->size() == 11);
http::check_requests();
assert(notifications.size() == 1); // Exactly one completion.
return diagnostics;
};
utils::http::result successful;
successful.code = CURLE_OK;
successful.status = 200;
successful.buffer = "accepted";
successful.dns_ms = 1;
successful.connect_ms = 2;
successful.tls_ms = 3;
successful.first_byte_ms = 4;
successful.total_ms = 5;
utils::http::next_result = successful;
auto diagnostics = send();
assert(notifications.front()[0].as<std::string>() == "accepted" && notifications.front()[1].as<bool>());
assert(notifications.front()[2].get_raw().type == game::SCRIPT_NONE);
assert(diagnostics["http_status"].as<int>() == 200 && diagnostics["curl_code"].as<int>() == 0);
assert(diagnostics["dns_ms"].as<double>() == 1 && diagnostics["connect_ms"].as<double>() == 2);
assert(diagnostics["tls_ms"].as<double>() == 3 && diagnostics["first_byte_ms"].as<double>() == 4 && diagnostics["total_ms"].as<double>() == 5);
utils::http::next_result->status = 500;
assert(send()["http_status"].as<int>() == 500 && notifications.front()[1].as<bool>());
utils::http::next_result->code = CURLE_OPERATION_TIMEDOUT;
assert(send()["curl_code"].as<int>() == 28 && !notifications.front()[1].as<bool>());
assert(notifications.front()[0].get_raw().type == game::SCRIPT_NONE && notifications.front()[2].is<std::string>());
utils::http::next_result = std::nullopt;
send();
assert(!notifications.front()[1].as<bool>());
utils::http::throw_from_transfer = true;
send();
assert(!notifications.front()[1].as<bool>());
utils::http::throw_from_transfer = false;
utils::http::next_result = successful;
utils::http::next_result->buffer = std::string(0x5001, 'x');
send();
assert(notifications.front()[0].as<std::string>().size() == 0x5000);
// A resumed waiter can enqueue enough work to reallocate the pending vector.
utils::http::next_result = successful;
notifications.clear();
http::create_request(params);
scheduler::thread_pool.run();
after_notify = [&]() { for (int i = 0; i < 100; ++i) http::create_request(params); };
http::check_requests();
after_notify = {};
assert(http::requests.size() == 100 && notifications.size() == 1);
scheduler::thread_pool.run();
http::check_requests();
assert(http::requests.empty() && notifications.size() == 101);
puts("\nPASS component: option validation, metadata, compatible argument order, single completion on failures/exceptions, reentrant queue");
}
+121
View File
@@ -0,0 +1,121 @@
// Minimal host interfaces for compiling the real component without the game VM.
#pragma once
#include "http.hpp"
#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <stdexcept>
#include <thread>
#include <variant>
#include <vector>
using namespace std::chrono_literals;
namespace game { constexpr int SCRIPT_NONE = 0; }
namespace plugin { class plugin {}; }
class component_interface
{
public:
virtual ~component_interface() = default;
virtual void on_startup(plugin::plugin*) {}
virtual void on_shutdown(plugin::plugin*) {}
};
#define REGISTER_COMPONENT(...)
namespace scripting
{
class array;
class script_value;
using fields = std::map<std::string, script_value>;
class script_value
{
public:
std::variant<std::monostate, int, bool, double, std::string, std::shared_ptr<fields>> data;
script_value() = default;
script_value(int v) : data(v) {}
script_value(bool v) : data(v) {}
script_value(double v) : data(v) {}
script_value(const char* v) : data(std::string(v)) {}
script_value(std::string v) : data(std::move(v)) {}
script_value(const array& v);
template <typename T> bool is() const { return std::holds_alternative<T>(data); }
template <typename T> T as() const { return std::get<T>(data); }
struct raw { int type; };
raw get_raw() const { return {static_cast<int>(data.index())}; }
std::string to_string() const { return as<std::string>(); }
};
class array
{
public:
std::shared_ptr<fields> data = std::make_shared<fields>();
script_value& operator[](const script_value& key) const { return (*data)[key.as<std::string>()]; }
std::vector<script_value> get_keys() const
{
std::vector<script_value> keys;
for (const auto& [key, value] : *data) keys.emplace_back(key);
return keys;
}
};
inline script_value::script_value(const array& v) : data(v.data) {}
template <> inline bool script_value::is<array>() const { return std::holds_alternative<std::shared_ptr<fields>>(data); }
template <> inline array script_value::as<array>() const
{
array result;
result.data = std::get<std::shared_ptr<fields>>(data);
return result;
}
using variadic_args = std::vector<script_value>;
class object
{
inline static unsigned next_id = 1;
unsigned id = next_id++;
public:
unsigned get_entity_id() const { return id; }
};
inline std::vector<std::vector<script_value>> notifications;
inline std::function<void()> after_notify;
inline void notify(unsigned, const std::string& event, const std::vector<script_value>& args)
{
assert(event == "done");
notifications.push_back(args);
if (after_notify) after_notify();
}
inline void on_shutdown(const std::function<void()>&) {}
}
namespace scheduler
{
struct pool
{
std::vector<std::function<void()>> tasks;
void push(std::function<void()> f) { tasks.push_back(std::move(f)); }
void run()
{
auto work = std::move(tasks);
tasks.clear();
for (auto& task : work) task();
}
};
inline pool thread_pool;
constexpr int server = 0;
inline void loop(const std::function<void()>&, int) {}
}
namespace gsc::function
{
template <typename F> void add(const char*, F) {}
template <typename F, typename... Names> void add_multiple(F, Names...) {}
}
namespace utils::http
{
inline bool throw_from_transfer = false;
inline std::optional<result> next_result = result{};
inline timeouts observed_limits;
inline std::optional<result> get_data(const std::string&, const std::string&, const headers&, const std::string&, const timeouts& limits)
{
observed_limits = limits;
if (throw_from_transfer) throw std::runtime_error("simulated worker error");
return next_result;
}
}
+26
View File
@@ -0,0 +1,26 @@
#include "http.hpp"
#include <chrono>
#include <cstdlib>
#include <iostream>
int main(int argc, char** argv)
{
if (argc != 4 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
return 2;
utils::http::timeouts limits;
// A dash exercises the defaults compiled into the actual plugin source.
if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]);
if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]);
const auto started = std::chrono::steady_clock::now();
const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}",
{{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits);
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - started).count();
if (!result) return 3;
std::cout << "{\"curl_code\":" << result->code << ",\"http_status\":" << result->status
<< ",\"dns_ms\":" << result->dns_ms << ",\"connect_ms\":" << result->connect_ms
<< ",\"tls_ms\":" << result->tls_ms << ",\"first_byte_ms\":" << result->first_byte_ms
<< ",\"total_ms\":" << result->total_ms << ",\"wall_ms\":" << wall_ms
<< ",\"body_size\":" << result->buffer.size() << "}\n";
curl_global_cleanup();
}