mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
Eight places sized a pool (pipeline workers, library prebuild, translator prewarm and seeding, the multithreaded ASTC decoder) by GetActiveProcessorCount, every logical processor of the machine. A process limited with start /affinity, Task Manager or a launcher then started a pool for 16 CPUs on 4. plat_cpu_usable counts the affinity mask (all active processors for a process that spans processor groups); plat_cpu_count, the machine's count, stays for the system info. Idea from MMadmer's UsableCpuCount in a fork of the Shadow of the Colossus PC port.
364 lines
18 KiB
C
364 lines
18 KiB
C
// Adapted from MacNativeGames games/totk/runtime/loader.c (lines 456-477) and services.c (lines 219-235) @ b75be8cf
|
|
// (MIT, Copyright (c) 2026 MacNativeGames contributors) and LAS Recomp runtime/main.c (lines 16-35, 141-159) @
|
|
// da35b83 (MIT, Copyright (c) 2026 Peiikai).
|
|
// The boot driver: the guest main thread runs the game's own nn::init::Start
|
|
// with three host callbacks, the second of which runs DT_INIT of main, subsdk0 and sdk; Start then calls nnMain.
|
|
// --boot checks two checkpoints on the way and ends at the first nvnBootstrapLoader call:
|
|
// SetFocusHandlingMode called from main+0xe7a954 (1.4.3: +0xbdb48c) in nnMain, every module's constructor flag
|
|
// reads 2
|
|
// nvnBootstrapLoader called from main+0xe7eb2c (1.4.3: +0xbe0360) in the graphics bring-up; logs the run
|
|
// statistics, exits 0
|
|
// With --seconds S the run continues past that call; rt_frame_presented ends it once the world clock passed S.
|
|
#include <windows.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "cfg.h"
|
|
#include "fs.h"
|
|
#include "gfx.h"
|
|
#include "nvn_api.h"
|
|
#include "platform/platform.h"
|
|
#include "vk.h"
|
|
|
|
#define SYM_INIT_START "_ZN2nn4init5StartEmmPFvvES2_S2_"
|
|
// main: return addresses of nnMain's SetFocusHandlingMode call (1.2.1 from this port's analysis of main; 1.4.3 from
|
|
// MacNativeGames' scripts/verify_boot.py) and of the first nvnBootstrapLoader call, per game version.
|
|
#define CHECKPOINT_RET (RT_V143 ? 0xbdb48cull : 0xe7a954ull)
|
|
#define NVN_RET (RT_V143 ? 0xbe0360ull : 0xe7eb2cull)
|
|
#define CTOR_DONE 2 // a module's constructor flag after its DT_INIT ran the init_array
|
|
|
|
// The init_array sizes (1.2.1: counted in the modules; 1.4.3: DT_INIT_ARRAYSZ); MacNativeGames' boot check expects
|
|
// the same.
|
|
typedef struct { const char *name; uint64_t entries; } InitCount;
|
|
static const InitCount inits_121[] = {{"main", 10673}, {"subsdk0", 45}, {"sdk", 116}};
|
|
static const InitCount inits_143[] = {{"main", 10717}, {"subsdk0", 45}, {"sdk", 83}};
|
|
#define expected_inits (RT_V143 ? inits_143 : inits_121)
|
|
|
|
static volatile LONG inits_done, checkpoint_seen;
|
|
uint64_t rt_guest_start_ns;
|
|
|
|
static const RtLoaded *module_named(const char *name) {
|
|
for (int i = 0; i < ldr_count(); i++)
|
|
if (!strcmp(ldr_module(i)->name, name)) return ldr_module(i);
|
|
rt_fatal("boot: module %s is not loaded", name);
|
|
}
|
|
|
|
static uint32_t ctor_state(const RtLoaded *m) { return LD32(m->ctor_flag); }
|
|
|
|
static const char *caller(const Cpu *c, char *buf, int n) { return ldr_describe(c->x[30] - 4, buf, n); }
|
|
|
|
// main.npdm META: the main thread's priority (+0xE), core (+0xF) and stack size (+0x1C). kernel.c builds the main
|
|
// thread with the 1.2.1 values; a different npdm means a different game build.
|
|
static void check_npdm(void) {
|
|
size_t n;
|
|
uint8_t *npdm = ldr_read_verified(rt_config()->exefs, "main.npdm", &n);
|
|
if (n < 0x80 || memcmp(npdm, "META", 4)) rt_fatal("boot: main.npdm has no META header");
|
|
uint32_t stack;
|
|
memcpy(&stack, npdm + 0x1C, 4);
|
|
int prio = npdm[0xE], core = npdm[0xF];
|
|
free(npdm);
|
|
if (prio != 44 || core != 0 || stack != 0x100000)
|
|
rt_fatal("boot: main.npdm asks for priority %d, core %d, stack %#x; kernel.c builds 44, 0, 0x100000", prio,
|
|
core, stack);
|
|
}
|
|
|
|
// ---- the three callbacks of nn::init::Start (rtld's job on hardware)
|
|
static void cb_loader_ready(Cpu *c) {
|
|
(void)c;
|
|
rt_log("boot: rtld callback: modules linked");
|
|
}
|
|
|
|
// DT_INIT registers the module with musl (__nnmusl_init_dso) and runs its init_array; the constructor flag reads 2
|
|
// afterwards (MacNativeGames loader.c:458-462).
|
|
static void cb_module_inits(Cpu *c) {
|
|
if (InterlockedCompareExchange(&inits_done, 0, 0)) rt_fatal("boot: module constructors requested twice");
|
|
for (unsigned k = 0; k < sizeof inits_121 / sizeof *inits_121; k++) {
|
|
const RtLoaded *m = module_named(expected_inits[k].name);
|
|
uint64_t entries = m->init_array_size / 8;
|
|
if (!m->init) rt_fatal("boot: %s has no DT_INIT", m->name);
|
|
rt_log("boot: ENTER %s DT_INIT (%llu init_array entries)", m->name, (unsigned long long)entries);
|
|
rt_call(c, m->init, 0);
|
|
uint32_t state = ctor_state(m);
|
|
rt_logf("boot: DT_INIT %s state=%u (%llu)\n", m->name, state, (unsigned long long)entries);
|
|
if (state != CTOR_DONE) rt_fatal("boot: %s DT_INIT left its constructor flag at %u, not 2", m->name, state);
|
|
if (entries != expected_inits[k].entries)
|
|
rt_fatal("boot: %s has %llu init_array entries, this game version has %llu", m->name, (unsigned long long)entries,
|
|
(unsigned long long)expected_inits[k].entries);
|
|
}
|
|
InterlockedExchange(&inits_done, 1);
|
|
}
|
|
|
|
static void cb_module_finis(Cpu *c) {
|
|
char lr[128];
|
|
rt_fatal("boot: the game asked for module finalizers (from %s): the guest exited", caller(c, lr, sizeof lr));
|
|
}
|
|
|
|
// ---- checkpoints (host.h)
|
|
static void stats_line(void) {
|
|
RtStats s = rt_stats();
|
|
rt_logf("boot: passed (%llu SVCs, %llu threads, %llu host calls)\n", (unsigned long long)s.svcs,
|
|
(unsigned long long)s.threads, (unsigned long long)s.host_calls);
|
|
}
|
|
|
|
void rt_boot_event(const char *what, Cpu *c, const char *detail) {
|
|
char at[128];
|
|
const RtLoaded *main_mod = module_named("main");
|
|
uint64_t ret = c->x[30];
|
|
caller(c, at, sizeof at);
|
|
if (!strcmp(what, "checkpoint")) {
|
|
if (ret != main_mod->base + CHECKPOINT_RET)
|
|
rt_fatal("boot: checkpoint %s reached from %s, expected main+%#llx", detail, at,
|
|
(unsigned long long)CHECKPOINT_RET);
|
|
if (!InterlockedCompareExchange(&inits_done, 0, 0)) rt_fatal("boot: checkpoint %s before DT_INIT", detail);
|
|
for (int i = 0; i < ldr_count(); i++)
|
|
if (ctor_state(ldr_module(i)) != CTOR_DONE)
|
|
rt_fatal("boot: checkpoint %s: %s constructor flag reads %u", detail, ldr_module(i)->name,
|
|
ctor_state(ldr_module(i)));
|
|
InterlockedExchange(&checkpoint_seen, 1);
|
|
rt_logf("boot: checkpoint %s from main+%#llx\n", detail, (unsigned long long)(ret - main_mod->base));
|
|
return;
|
|
}
|
|
if (!strcmp(what, "nvn")) {
|
|
if (ret != main_mod->base + NVN_RET)
|
|
rt_fatal("boot: first NVN call %s(\"%s\") from %s, expected main+%#llx", "nvnBootstrapLoader", detail, at,
|
|
(unsigned long long)NVN_RET);
|
|
if (!InterlockedCompareExchange(&checkpoint_seen, 0, 0))
|
|
rt_fatal("boot: first NVN call before the SetFocusHandlingMode checkpoint");
|
|
rt_logf("boot: first NVN call nvnBootstrapLoader(\"%s\") from main+%#llx\n", detail,
|
|
(unsigned long long)(ret - main_mod->base));
|
|
if (rt_config()->seconds) return; // --seconds: the run goes on into the world
|
|
if (rt_trace & RT_TRACE_HOSTCALL) hostfn_report();
|
|
stats_line();
|
|
fflush(stdout);
|
|
fflush(stderr);
|
|
_exit(0); // other guest threads are still running; nothing of theirs needs an orderly end
|
|
}
|
|
rt_fatal("boot: unknown boot event %s (%s) from %s", what, detail ? detail : "", at);
|
|
}
|
|
|
|
// ---- --seconds S
|
|
// A PNG with stored (uncompressed) deflate blocks: no compression library, and the shot is only read by tools.
|
|
// rt_write_png (declared in vk.h) is shared with the TOTK_DUMP frame dump (vk_dump.c).
|
|
static uint32_t crc32_update(uint32_t crc, const uint8_t *p, size_t n) {
|
|
static uint32_t table[256];
|
|
if (!table[1])
|
|
for (uint32_t i = 0; i < 256; i++) {
|
|
uint32_t c = i;
|
|
for (int k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
|
table[i] = c;
|
|
}
|
|
for (size_t i = 0; i < n; i++) crc = table[(crc ^ p[i]) & 0xFF] ^ (crc >> 8);
|
|
return crc;
|
|
}
|
|
|
|
static void png_chunk(FILE *f, const char *type, const uint8_t *data, uint32_t n) {
|
|
uint8_t hdr[8] = {(uint8_t)(n >> 24), (uint8_t)(n >> 16), (uint8_t)(n >> 8), (uint8_t)n};
|
|
memcpy(hdr + 4, type, 4);
|
|
uint32_t crc = crc32_update(crc32_update(~0u, hdr + 4, 4), data, n) ^ ~0u;
|
|
uint8_t tail[4] = {(uint8_t)(crc >> 24), (uint8_t)(crc >> 16), (uint8_t)(crc >> 8), (uint8_t)crc};
|
|
fwrite(hdr, 1, 8, f);
|
|
if (n) fwrite(data, 1, n, f);
|
|
fwrite(tail, 1, 4, f);
|
|
}
|
|
|
|
void rt_write_png(const char *path, const uint8_t *rgba, uint32_t w, uint32_t h) {
|
|
uint64_t row = 1 + 4ull * w, raw_n = row * h;
|
|
uint8_t *raw = malloc(raw_n);
|
|
if (!raw) rt_fatal("boot: out of memory for the shot");
|
|
for (uint32_t y = 0; y < h; y++) {
|
|
raw[y * row] = 0; // filter: none
|
|
memcpy(raw + y * row + 1, rgba + 4ull * w * y, 4ull * w);
|
|
}
|
|
uint64_t blocks = (raw_n + 65534) / 65535;
|
|
uint8_t *z = malloc(2 + raw_n + 5 * blocks + 4);
|
|
if (!z) rt_fatal("boot: out of memory for the shot");
|
|
uint64_t zn = 0;
|
|
z[zn++] = 0x78, z[zn++] = 0x01;
|
|
uint32_t a = 1, b = 0;
|
|
for (uint64_t off = 0; off < raw_n; off += 65535) {
|
|
uint32_t k = raw_n - off > 65535 ? 65535 : (uint32_t)(raw_n - off);
|
|
z[zn++] = off + k >= raw_n;
|
|
z[zn++] = (uint8_t)k, z[zn++] = (uint8_t)(k >> 8), z[zn++] = (uint8_t)~k, z[zn++] = (uint8_t)(~k >> 8);
|
|
memcpy(z + zn, raw + off, k);
|
|
zn += k;
|
|
for (uint32_t i = 0; i < k; i++) a = (a + raw[off + i]) % 65521, b = (b + a) % 65521;
|
|
}
|
|
uint32_t ad = b << 16 | a;
|
|
z[zn++] = (uint8_t)(ad >> 24), z[zn++] = (uint8_t)(ad >> 16), z[zn++] = (uint8_t)(ad >> 8), z[zn++] = (uint8_t)ad;
|
|
FILE *f = fopen(path, "wb");
|
|
if (!f) rt_fatal("boot: cannot write %s", path);
|
|
static const uint8_t sig[8] = {0x89, 'P', 'N', 'G', 13, 10, 26, 10};
|
|
uint8_t ihdr[13] = {(uint8_t)(w >> 24), (uint8_t)(w >> 16), (uint8_t)(w >> 8), (uint8_t)w,
|
|
(uint8_t)(h >> 24), (uint8_t)(h >> 16), (uint8_t)(h >> 8), (uint8_t)h, 8, 6, 0, 0, 0};
|
|
fwrite(sig, 1, 8, f);
|
|
png_chunk(f, "IHDR", ihdr, 13);
|
|
png_chunk(f, "IDAT", z, (uint32_t)zn);
|
|
png_chunk(f, "IEND", NULL, 0);
|
|
if (fclose(f)) rt_fatal("boot: cannot write %s", path);
|
|
free(z);
|
|
free(raw);
|
|
}
|
|
|
|
static void save_shot(const tex_t *t, const char *path) {
|
|
int fmt = t->d.format;
|
|
int bgra = fmt == NVN_FORMAT_BGRA8 || fmt == NVN_FORMAT_BGRA8_SRGB;
|
|
if (!bgra && fmt != NVN_FORMAT_RGBA8 && fmt != NVN_FORMAT_RGBA8_SRGB)
|
|
rt_fatal("boot: --shot supports 8-bit RGBA and BGRA targets, the presented texture has NVN format %#x", fmt);
|
|
uint32_t w = t->d.width, h = t->d.height;
|
|
uint8_t *px = malloc(4ull * w * h);
|
|
if (!px) rt_fatal("boot: out of memory for the shot");
|
|
view_t view;
|
|
memset(&view, 0, sizeof view);
|
|
NVNcopyRegion r = {.width = (int)w, .height = (int)h, .depth = 1};
|
|
if (gfx_replay_async()) gfx_backend()->finish(); // the presented frame may still be queued for the replay
|
|
gfx_backend()->read_texels(t, &view, &r, px, 4ull * w, 4ull * w * h);
|
|
if (bgra)
|
|
for (uint64_t i = 0; i < 4ull * w * h; i += 4) {
|
|
uint8_t x = px[i];
|
|
px[i] = px[i + 2];
|
|
px[i + 2] = x;
|
|
}
|
|
rt_write_png(path, px, w, h);
|
|
free(px);
|
|
rt_logf("boot: shot %s (%ux%u)\n", path, w, h);
|
|
}
|
|
|
|
// TOTK_SHOTS=lo-hi:step [TOTK_SHOTS_DIR=dir]: every step-th present in [lo, hi] saved as <dir>/f<n>.png (n the
|
|
// present), box-filtered to 480x270; TOTK_SHOTS=w<lo>-<hi>:<step> takes them by world-clock seconds instead, the first
|
|
// present at or after each step (full size with TOTK_SHOTS_FULL=1), for finding short glitches. Pipeline builds stay asynchronous, so the
|
|
// pictures show what a player sees.
|
|
static void series_shot(const tex_t *t, uint64_t n) {
|
|
static int parsed;
|
|
static unsigned long long lo, hi, step, next;
|
|
static const char *dir;
|
|
static int full, world;
|
|
if (!parsed) {
|
|
full = cfg_get("TOTK_SHOTS_FULL") != NULL;
|
|
const char *e = cfg_get("TOTK_SHOTS");
|
|
double wl, wh, ws;
|
|
if (e && e[0] == 'w' && sscanf(e + 1, "%lf-%lf:%lf", &wl, &wh, &ws) == 3 && ws > 0) // world-clock seconds
|
|
world = 1, lo = (unsigned long long)(wl * 1e6), hi = (unsigned long long)(wh * 1e6),
|
|
step = (unsigned long long)(ws * 1e6), next = lo;
|
|
else if (!e || sscanf(e, "%llu-%llu:%llu", &lo, &hi, &step) != 3 || !step) hi = 0;
|
|
dir = cfg_get("TOTK_SHOTS_DIR") ? cfg_get("TOTK_SHOTS_DIR") : ".";
|
|
if (hi) CreateDirectoryA(dir, NULL);
|
|
parsed = 1;
|
|
}
|
|
if (world) { // the first present at or after each step of the world clock
|
|
int64_t w = frame_step_world_us();
|
|
if (w < 0 || (unsigned long long)w < next || next > hi) return;
|
|
while (next <= (unsigned long long)w) next += step;
|
|
} else if (n < lo || n > hi || (n - lo) % step) {
|
|
return;
|
|
}
|
|
int fmt = t->d.format;
|
|
int bgra = fmt == NVN_FORMAT_BGRA8 || fmt == NVN_FORMAT_BGRA8_SRGB;
|
|
if (!bgra && fmt != NVN_FORMAT_RGBA8 && fmt != NVN_FORMAT_RGBA8_SRGB) return;
|
|
uint32_t w = t->d.width, h = t->d.height, ow = full ? w : 480, oh = full ? h : 270;
|
|
uint8_t *px = malloc(4ull * w * h), *out = malloc(4ull * ow * oh);
|
|
if (!px || !out) rt_fatal("boot: out of memory for a TOTK_SHOTS frame");
|
|
view_t view;
|
|
memset(&view, 0, sizeof view);
|
|
NVNcopyRegion r = {.width = (int)w, .height = (int)h, .depth = 1};
|
|
if (gfx_replay_async()) gfx_backend()->finish(); // the presented frame may still be queued for the replay
|
|
gfx_backend()->read_texels(t, &view, &r, px, 4ull * w, 4ull * w * h);
|
|
for (uint32_t y = 0; y < oh; y++)
|
|
for (uint32_t x = 0; x < ow; x++) {
|
|
uint32_t x0 = x * w / ow, x1 = (x + 1) * w / ow, y0 = y * h / oh, y1 = (y + 1) * h / oh;
|
|
if (x1 <= x0) x1 = x0 + 1;
|
|
if (y1 <= y0) y1 = y0 + 1;
|
|
uint32_t sum[4] = {0}, k = 0;
|
|
for (uint32_t sy = y0; sy < y1; sy++)
|
|
for (uint32_t sx = x0; sx < x1; sx++, k++)
|
|
for (int c = 0; c < 4; c++) sum[c] += px[4ull * (sy * (uint64_t)w + sx) + (uint32_t)c];
|
|
uint8_t *o = out + 4ull * (y * ow + x);
|
|
for (int c = 0; c < 4; c++) o[c] = (uint8_t)(sum[bgra && c != 3 ? 2 - c : c] / k);
|
|
o[3] = 255;
|
|
}
|
|
char path[MAX_PATH];
|
|
snprintf(path, sizeof path, "%s/f%05llu.png", dir, (unsigned long long)n);
|
|
rt_write_png(path, out, ow, oh);
|
|
free(out);
|
|
free(px);
|
|
}
|
|
|
|
// A --seconds run ends at the first present after the world clock (game time since the save's first frame,
|
|
// frame_step_world_us) passed S seconds, so a run covers the same game time at any frame rate; any other frame is
|
|
// counted by the back end only.
|
|
void rt_frame_presented(const tex_t *t, uint64_t n) {
|
|
const RtConfig *cfg = rt_config();
|
|
series_shot(t, n);
|
|
int64_t w = cfg->seconds ? frame_step_world_us() : -1;
|
|
if (w < 0 || (double)w < cfg->seconds * 1e6) return;
|
|
if (cfg->shot) save_shot(t, cfg->shot);
|
|
GfxStats g = gfx_stats();
|
|
rt_logf("boot: frames %llu presented (nvn calls %llu, distinct %llu, unimplemented %llu, validation errors %llu, "
|
|
"shader failures %llu, draws skipped %llu)\n", (unsigned long long)g.frames,
|
|
(unsigned long long)g.nvn_calls, (unsigned long long)g.nvn_distinct, (unsigned long long)g.unimplemented,
|
|
(unsigned long long)g.validation_errors, (unsigned long long)g.shader_failures,
|
|
(unsigned long long)g.draws_skipped);
|
|
stats_line();
|
|
fflush(stdout);
|
|
fflush(stderr);
|
|
fs_save_flush();
|
|
vk_pipeline_save(); // no-op without the Vulkan back end
|
|
prof_write();
|
|
fflush(stderr);
|
|
_exit(0);
|
|
}
|
|
|
|
// ---- steps 9-14
|
|
static uint64_t cb_addr[3];
|
|
|
|
static void guest_main(Cpu *c) {
|
|
uint32_t handle = kern_main_thread(&c);
|
|
uint64_t start = ldr_symbol(SYM_INIT_START);
|
|
if (!start) rt_fatal("boot: %s is not exported", SYM_INIT_START);
|
|
char d[128];
|
|
rt_log("boot: ENTER nn::init::Start at %s, main thread %#x", ldr_describe(start, d, sizeof d), handle);
|
|
// nn::init::Start(main thread handle, arguments region, loader_ready, module_inits, module_finis)
|
|
rt_call(c, start, 5, (uint64_t)handle, (uint64_t)0, cb_addr[0], cb_addr[1], cb_addr[2]);
|
|
rt_fatal("boot: nn::init::Start returned");
|
|
}
|
|
|
|
static DWORD WINAPI prewarm_main(void *arg) {
|
|
(void)arg;
|
|
vk_pipeline_prewarm();
|
|
const char *prep = cfg_get("TOTK_TEST_PREPARE_SHADERS"); // Task 2.3 test hook; Task 2.6 builds the preparation
|
|
if (prep && !strcmp(prep, "1")) {
|
|
int cpus = (int)plat_cpu_usable();
|
|
gfx_shader_prepare_recorded(cpus, cpus / 2 > 16 ? 16 : cpus / 2 < 1 ? 1 : cpus / 2);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void rt_boot(void) {
|
|
check_npdm();
|
|
if (!rt_config()->shot) vk_pipeline_async_enable(); // a --shot image must not depend on build timing
|
|
// Shaders and pipelines recorded in earlier runs are made ready while the modules load; the guest waits for them.
|
|
HANDLE warm = CreateThread(NULL, 0, prewarm_main, NULL, 0, NULL);
|
|
ldr_load(rt_config()->exefs);
|
|
rt_patches_init();
|
|
frame_step_init();
|
|
res_init();
|
|
cb_addr[0] = hostfn_register("<rtld loader_ready>", cb_loader_ready);
|
|
cb_addr[1] = hostfn_register("<rtld module_inits>", cb_module_inits);
|
|
cb_addr[2] = hostfn_register("<rtld module_finis>", cb_module_finis);
|
|
hostfn_seal();
|
|
ULONGLONG held = GetTickCount64();
|
|
if (warm) WaitForSingleObject(warm, INFINITE), CloseHandle(warm);
|
|
else vk_pipeline_prewarm();
|
|
held = GetTickCount64() - held;
|
|
FILETIME created, x0, x1, x2, now;
|
|
GetProcessTimes(GetCurrentProcess(), &created, &x0, &x1, &x2);
|
|
GetSystemTimeAsFileTime(&now);
|
|
ULARGE_INTEGER c = {.LowPart = created.dwLowDateTime, .HighPart = created.dwHighDateTime},
|
|
n = {.LowPart = now.dwLowDateTime, .HighPart = now.dwHighDateTime};
|
|
rt_log("boot: guest starts %llu ms after process start (%llu ms of it waiting for the prewarm)",
|
|
(unsigned long long)((n.QuadPart - c.QuadPart) / 10000), (unsigned long long)held);
|
|
rt_guest_start_ns = kern_now_ns();
|
|
kern_run_on_guest_main(guest_main);
|
|
rt_fatal("boot: the guest main thread ended");
|
|
}
|