714 lines
25 KiB
C++

// Released under the MIT License. See LICENSE for details.
#include "ballistica/base/logic/logic.h"
#include <Python.h>
#include "ballistica/base/app_adapter/app_adapter.h"
#include "ballistica/base/app_mode/app_mode.h"
#include "ballistica/base/audio/audio.h"
#include "ballistica/base/input/input.h"
#include "ballistica/base/networking/networking.h"
#include "ballistica/base/platform/base_platform.h"
#include "ballistica/base/python/base_python.h"
#include "ballistica/base/support/context.h"
#include "ballistica/base/support/plus_soft.h"
#include "ballistica/base/support/stdio_console.h"
#include "ballistica/base/ui/dev_console.h"
#include "ballistica/base/ui/ui.h"
#include "ballistica/shared/foundation/event_loop.h"
namespace ballistica::base {
Logic::Logic() : display_timers_(new TimerList()) {
// Enable display-time debug logs via env var.
auto val = g_core->platform->GetEnv("BA_DEBUG_LOG_DISPLAY_TIME");
if (val && *val == "1") {
debug_log_display_time_ = true;
}
}
void Logic::OnMainThreadStartApp() {
// Spin up our logic thread and sit and wait for it to init.
event_loop_ = new EventLoop(EventLoopID::kLogic);
g_core->suspendable_event_loops.push_back(event_loop_);
event_loop_->PushCallSynchronous([this] { OnAppStart(); });
}
void Logic::OnAppStart() {
assert(g_base->InLogicThread());
g_core->LifecycleLog("on-app-start begin (logic thread)");
// Our thread should not be holding the GIL here at the start (and
// probably will not have any Python state at all). So here we set both
// of those up.
assert(!PyGILState_Check());
PyGILState_Ensure();
// Code running in the logic thread holds the GIL by default.
event_loop_->SetAcquiresPythonGIL();
// Stay informed when our event loop is pausing/unpausing.
event_loop_->AddSuspendCallback(
NewLambdaRunnableUnmanaged([this] { OnAppSuspend(); }));
event_loop_->AddUnsuspendCallback(
NewLambdaRunnableUnmanaged([this] { OnAppUnsuspend(); }));
// Running in a specific order here and should try to stick to it in
// other OnAppXXX callbacks so any subsystem interdependencies behave
// consistently. When pausing or shutting-down we use the opposite order for
// the same reason. Let's do Python last (or first when pausing, etc) since
// it will be the most variable; that way it will interact with other
// subsystems in their normal states which is less likely to lead to
// problems.
g_base->app_adapter->OnAppStart();
g_base->platform->OnAppStart();
g_base->graphics->OnAppStart();
g_base->audio->OnAppStart();
g_base->input->OnAppStart();
g_base->ui->OnAppStart();
g_base->app_mode()->OnAppStart();
if (g_base->HavePlus()) {
g_base->plus()->OnAppStart();
}
g_base->python->OnAppStart();
g_core->LifecycleLog("on-app-start end (logic thread)");
}
void Logic::OnGraphicsReady() {
assert(g_base->InLogicThread());
if (graphics_ready_) {
// Only want to fire this logic the first time.
return;
}
graphics_ready_ = true;
// Ok; graphics-server is telling us we've got a screen (or no screen in
// the case of headless-mode). We use this as a cue to kick off our
// business logic.
// Let the Python layer know the native layer is now fully functional.
// This will probably result in the Python layer flipping to the INITING
// state.
CompleteAppBootstrapping_();
if (g_core->HeadlessMode()) {
// Normally we step display-time as part of our frame-drawing process.
// If we're headless, we're not drawing any frames, but we still want to
// do minimal processing on any display-time timers so code doesn't
// break. Let's run at a low-ish rate (10hz) to keep things efficient.
// Anyone dealing in display-time should be able to handle a wide
// variety of rates anyway. NOTE: This length is currently milliseconds.
headless_display_time_step_timer_ = event_loop()->NewTimer(
kHeadlessMinDisplayTimeStep, true,
NewLambdaRunnable([this] { StepDisplayTime_(); }).Get());
} else {
// In gui mode, push an initial frame to the graphics server. From this
// point it will be self-sustaining, sending us a frame request each
// time it receives a new frame from us.
g_base->graphics->BuildAndPushFrameDef();
}
}
void Logic::CompleteAppBootstrapping_() {
assert(g_base->InLogicThread());
assert(g_base->CurrentContext().IsEmpty());
assert(!app_bootstrapping_complete_);
app_bootstrapping_complete_ = true;
g_core->LifecycleLog("app native bootstrapping complete");
// Let the assets system know it can start loading stuff now that
// we have a screen and thus know texture formats/etc.
// TODO(ericf): It might be nice to kick this off earlier if our logic is
// robust enough to create some sort of 'null' textures/meshes before
// the renderer is ready and then seamlessly create renderer-specific
// ones once the renderer is up. We could likely at least get a lot
// of preloads done in the meantime. Though this would require preloads
// to be renderer-agnostic; not sure if that will always be the case.
g_base->assets->StartLoading();
// Let base know it can create the console or other asset-dependent things.
g_base->OnAssetsAvailable();
// Set up our timers.
process_pending_work_timer_ = event_loop()->NewTimer(
0, true, NewLambdaRunnable([this] { ProcessPendingWork_(); }).Get());
// asset_prune_timer_ = event_loop()->NewTimer(
// 2345 * 1000, true, NewLambdaRunnable([] { g_base->assets->Prune();
// }).Get());
// Let our initial dummy app-mode know it has become active.
g_base->app_mode()->OnActivate();
// Reset our various subsystems to a default state.
g_base->ui->Reset();
g_base->input->Reset();
g_base->graphics->Reset();
g_base->python->Reset();
g_base->audio->Reset();
// Let Python know we're done bootstrapping so it can flip the app
// into the 'launching' state.
g_base->python->objs()
.Get(BasePython::ObjID::kAppOnNativeBootstrappingCompleteCall)
.Call();
UpdatePendingWorkTimer_();
}
void Logic::OnAppRunning() {
assert(g_base->InLogicThread());
assert(g_base->CurrentContext().IsEmpty());
// Currently don't do anything here.
}
void Logic::OnInitialAppModeSet() {
assert(g_base->InLogicThread());
assert(g_base->CurrentContext().IsEmpty());
// We want any sort of raw Python input to only start accepting commands
// once we've got an initial app-mode set. Generally said commands will
// assume we're running in that mode and will fail if run before it is set.
if (auto* console = g_base->ui->dev_console()) {
console->EnableInput();
}
if (g_base->stdio_console) {
g_base->stdio_console->Start();
}
}
void Logic::OnAppSuspend() {
assert(g_base->InLogicThread());
assert(g_base->CurrentContext().IsEmpty());
// Note: keep these in opposite order of OnAppStart.
g_base->python->OnAppSuspend();
if (g_base->HavePlus()) {
g_base->plus()->OnAppSuspend();
}
g_base->app_mode()->OnAppSuspend();
g_base->ui->OnAppSuspend();
g_base->input->OnAppSuspend();
g_base->audio->OnAppSuspend();
g_base->graphics->OnAppSuspend();
g_base->platform->OnAppSuspend();
g_base->app_adapter->OnAppSuspend();
}
void Logic::OnAppUnsuspend() {
assert(g_base->InLogicThread());
assert(g_base->CurrentContext().IsEmpty());
// Note: keep these in the same order as OnAppStart.
g_base->app_adapter->OnAppUnsuspend();
g_base->platform->OnAppUnsuspend();
g_base->graphics->OnAppUnsuspend();
g_base->audio->OnAppUnsuspend();
g_base->input->OnAppUnsuspend();
g_base->ui->OnAppUnsuspend();
g_base->app_mode()->OnAppUnsuspend();
if (g_base->HavePlus()) {
g_base->plus()->OnAppUnsuspend();
}
g_base->python->OnAppUnsuspend();
}
void Logic::Shutdown() {
assert(g_base->InLogicThread());
assert(g_base->IsAppStarted());
if (!shutting_down_) {
shutting_down_ = true;
OnAppShutdown();
}
}
void Logic::OnAppShutdown() {
assert(g_core);
assert(g_base->CurrentContext().IsEmpty());
assert(shutting_down_);
// Nuke the app from orbit if we get stuck while shutting down.
g_core->StartSuicideTimer("shutdown", 10000);
// Tell base to disallow shutdown-suppressors from here on out.
g_base->ShutdownSuppressDisallow();
// Let our logic thread subsystems know we're shutting down.
// Note: Keep these in opposite order of OnAppStart.
// Note2: Any shutdown processes that take a non-zero amount of time
// should be registered as shutdown-tasks
g_base->python->OnAppShutdown();
if (g_base->HavePlus()) {
g_base->plus()->OnAppShutdown();
}
g_base->app_mode()->OnAppShutdown();
g_base->ui->OnAppShutdown();
g_base->input->OnAppShutdown();
g_base->audio->OnAppShutdown();
g_base->graphics->OnAppShutdown();
g_base->platform->OnAppShutdown();
g_base->app_adapter->OnAppShutdown();
}
void Logic::CompleteShutdown() {
BA_PRECONDITION(g_base->InLogicThread());
BA_PRECONDITION(shutting_down_);
BA_PRECONDITION(!shutdown_completed_);
shutdown_completed_ = true;
OnAppShutdownComplete();
}
void Logic::OnAppShutdownComplete() {
assert(g_base->InLogicThread());
// Wrap up any last business here in the logic thread and then kick things
// over to the main thread to exit out of the main loop.
// Let our logic subsystems know in case there's any last thing they'd
// like to do right before we exit.
// Note: Keep these in opposite order of OnAppStart.
// Note2: Any shutdown processes that take a non-zero amount of time
// should be registered as shutdown-tasks.
g_base->python->OnAppShutdownComplete();
if (g_base->HavePlus()) {
g_base->plus()->OnAppShutdownComplete();
}
g_base->app_mode()->OnAppShutdownComplete();
g_base->ui->OnAppShutdownComplete();
g_base->input->OnAppShutdownComplete();
g_base->audio->OnAppShutdownComplete();
g_base->graphics->OnAppShutdownComplete();
g_base->platform->OnAppShutdownComplete();
g_base->app_adapter->OnAppShutdownComplete();
g_base->app_adapter->PushMainThreadCall(
[] { g_base->OnAppShutdownComplete(); });
}
void Logic::DoApplyAppConfig() {
assert(g_base->InLogicThread());
// Give all our other subsystems a chance.
// Note: keep these in the same order as OnAppStart.
g_base->app_adapter->DoApplyAppConfig();
g_base->platform->DoApplyAppConfig();
g_base->graphics->DoApplyAppConfig();
g_base->audio->DoApplyAppConfig();
g_base->input->DoApplyAppConfig();
g_base->ui->DoApplyAppConfig();
g_base->app_mode()->DoApplyAppConfig();
if (g_base->HavePlus()) {
g_base->plus()->DoApplyAppConfig();
}
g_base->python->DoApplyAppConfig();
// Inform some other subsystems even though they're not our standard
// set of logic-thread-based ones.
g_base->networking->DoApplyAppConfig();
applied_app_config_ = true;
}
void Logic::OnScreenSizeChange(float virtual_width, float virtual_height,
float pixel_width, float pixel_height) {
assert(g_base->InLogicThread());
// Inform all subsystems.
// Note: keep these in the same order as OnAppStart.
g_base->app_adapter->OnScreenSizeChange();
g_base->platform->OnScreenSizeChange();
g_base->graphics->OnScreenSizeChange();
g_base->audio->OnScreenSizeChange();
g_base->input->OnScreenSizeChange();
g_base->ui->OnScreenSizeChange();
g_core->platform->OnScreenSizeChange();
g_base->app_mode()->OnScreenSizeChange();
if (g_base->HavePlus()) {
g_base->plus()->OnScreenSizeChange();
}
g_base->python->OnScreenSizeChange();
}
// Bring all logic-thread stuff up to date for a new visual frame.
void Logic::StepDisplayTime_() {
assert(g_base->InLogicThread());
// We have two different modes of operation here. When running in headless
// mode, display time is driven by upcoming events such as sim steps; we
// basically want to sleep as long as we can and run steps exactly when
// events occur. When running with a gui, our display-time is driven by
// real draw times and is intended to keep frame intervals as visually
// consistent and smooth looking as possible.
if (g_core->HeadlessMode()) {
UpdateDisplayTimeForHeadlessMode_();
} else {
UpdateDisplayTimeForFrameDraw_();
}
// Give all our subsystems some update love.
// Note: keep these in the same order as OnAppStart.
g_base->graphics->StepDisplayTime();
g_base->audio->StepDisplayTime();
g_base->input->StepDisplayTime();
g_base->ui->StepDisplayTime();
g_core->platform->StepDisplayTime();
g_base->app_mode()->StepDisplayTime();
if (g_base->HavePlus()) {
g_base->plus()->StepDisplayTime();
}
g_base->python->StepDisplayTime();
// Let's run display-timers *after* we step everything else so most things
// they interact with will be in an up-to-date state.
display_timers_->Run(display_time_microsecs_);
if (g_core->HeadlessMode()) {
PostUpdateDisplayTimeForHeadlessMode_();
}
}
void Logic::OnAppModeChanged() {
assert(g_base->InLogicThread());
// Kick our headless stepping into high gear; this will snap us out of any
// long sleep we're currently in the middle of.
if (g_core->HeadlessMode()) {
if (debug_log_display_time_) {
Log(LogLevel::kDebug,
"Resetting headless display step timer due to app-mode change.");
}
assert(headless_display_time_step_timer_);
headless_display_time_step_timer_->SetLength(kHeadlessMinDisplayTimeStep);
}
}
void Logic::UpdateDisplayTimeForHeadlessMode_() {
assert(g_base->InLogicThread());
// In this case we just keep display time synced up with app time; we
// don't care about keeping the increments smooth or consistent.
// The one thing we *do* try to do, however, is keep our timer length
// updated so that we fire exactly when the next app-mode event is
// scheduled (or at least close enough so we can fudge it and tell them
// its that exact time).
auto app_time_microsecs = g_core->GetAppTimeMicrosecs();
// Set our int based time vals so we can exactly hit timers.
auto old_display_time_microsecs = display_time_microsecs_;
display_time_microsecs_ = app_time_microsecs;
display_time_increment_microsecs_ =
display_time_microsecs_ - old_display_time_microsecs;
// And then our float time vals are driven by our int ones.
display_time_ = static_cast<double>(display_time_microsecs_) / 1000000.0;
display_time_increment_ =
static_cast<double>(display_time_increment_microsecs_) / 1000000.0;
if (debug_log_display_time_) {
char buffer[256];
snprintf(buffer, sizeof(buffer), "stepping display-time at app-time %.4f",
static_cast<double>(app_time_microsecs) / 1000000.0);
Log(LogLevel::kDebug, buffer);
}
}
void Logic::PostUpdateDisplayTimeForHeadlessMode_() {
assert(g_base->InLogicThread());
// At this point we've stepped our app-mode, so let's ask it how long
// we've got until the next event. We'll plug this into our display-update
// timer so we can try to sleep exactly until that point.
auto headless_display_step_microsecs =
std::max(std::min(g_base->app_mode()->GetHeadlessNextDisplayTimeStep(),
kHeadlessMaxDisplayTimeStep),
kHeadlessMinDisplayTimeStep);
if (debug_log_display_time_) {
auto sleepsecs =
static_cast<double>(headless_display_step_microsecs) / 1000000.0;
auto apptimesecs = g_core->GetAppTimeSeconds();
char buffer[256];
snprintf(buffer, sizeof(buffer),
"will try to sleep for %.4f at app-time %.4f (until %.4f)",
sleepsecs, apptimesecs, apptimesecs + sleepsecs);
Log(LogLevel::kDebug, buffer);
}
auto sleep_microsecs = headless_display_step_microsecs;
headless_display_time_step_timer_->SetLength(sleep_microsecs);
}
void Logic::UpdateDisplayTimeForFrameDraw_() {
// Here we update our smoothed display-time-increment based on how fast we
// are currently rendering frames. We want display-time to basically be
// progressing at the same rate as app-time but in as constant of a manner
// as possible so that animation, simulation-stepping/etc. appears smooth
// (using app-times within renders exhibits quite a bit of jitter). Though
// we also don't want it to be *too* smooth; drops in framerate should
// still be reflected quickly in display-time-increment otherwise it can
// look like the game is slowing down or speeding up.
// Flip debug-log-display-time on to debug this stuff.
// Things to look for:
// - 'final' value should mostly stay constant.
// - 'final' value should not be *too* far from 'current'.
// - 'current' should mostly show '(avg)'; rarely '(sample)'.
// - these can vary briefly during load spikes/etc. but should quickly
// reconverge to stability. If not, this may need further calibration.
auto current_app_time = g_core->GetAppTimeSeconds();
// We handle the first measurement specially.
if (last_display_time_update_app_time_ < 0) {
last_display_time_update_app_time_ = current_app_time;
} else {
auto this_increment = current_app_time - last_display_time_update_app_time_;
last_display_time_update_app_time_ = current_app_time;
// Store increments into a looping buffer.
if (recent_display_time_increments_index_ < 0) {
// For the first sample we fill all entries.
for (auto& recent_display_time_increment :
recent_display_time_increments_) {
recent_display_time_increment = this_increment;
}
recent_display_time_increments_index_ = 0;
} else {
recent_display_time_increments_[recent_display_time_increments_index_] =
this_increment;
recent_display_time_increments_index_ =
(recent_display_time_increments_index_ + 1) % kDisplayTimeSampleCount;
}
double avg{};
double min, max;
min = max = recent_display_time_increments_[0];
for (int i = 0; i < kDisplayTimeSampleCount; ++i) {
auto val = recent_display_time_increments_[i];
avg += val;
min = std::min(min, val);
max = std::max(max, val);
}
avg /= kDisplayTimeSampleCount;
double range = max - min;
// If our range of recent increment values is somewhat large relative to
// an average value, things are probably chaotic, so just use the
// current value to respond quickly to changes. If things are more calm,
// use our nice smoothed value.
// Let's use 1.0 as a final 'chaos' threshold to make logs easy to read.
// So our key fudge factor here is chaos_fudge. The higher this value,
// the lower chaos will be and thus the more the engine will stick to
// smoothed values. A good way to determine if this value is too high is
// to launch the game and watch the menu animation. If it visibly speeds
// up or slows down in a 'rubber band' looking way the moment after
// launch, it means the value is too high and the engine is sticking
// with smoothed values when it should instead be reacting immediately.
// So basically this value should be as high as possible while avoiding
// that look.
double chaos_fudge{1.25};
double chaos = (range / avg) / chaos_fudge;
bool use_avg = chaos < 1.0;
auto used = use_avg ? avg : this_increment;
// Lastly use this 'used' value to update our actual increment - our
// increment moves only if 'used' value gets farther than [trail_buffer]
// from it. So ideally it will sit in the middle of the smoothed value
// range.
// How far the smoothed increment value needs to get away from the
// current smooth value to actually start moving it. Example: If our
// smooth increment is 16.6ms (60fps), don't change our increment until
// the 'used' value is more than 0.5ms (16.6 * 0.03) from it in either
// direction.
// Note: In practice I'm seeing that higher framerates like 120 need
// buffers that are larger relative to avg to remain stable. Though
// perhaps a bit of jitter is not noticeable at high frame rates; just
// something to keep an eye on.
auto trail_buffer{avg * 0.03};
auto trailing_diff = used - display_time_increment_;
auto trailing_dist = std::abs(trailing_diff);
if (trailing_dist > trail_buffer) {
auto offs =
(trailing_dist - trail_buffer) * (trailing_diff > 0.0 ? 1.0 : -1.0);
if (debug_log_display_time_) {
char buffer[256];
snprintf(buffer, sizeof(buffer),
"trailing_dist %.6f > trail_buffer %.6f; will offset %.6f).",
trailing_dist, trail_buffer, offs);
Log(LogLevel::kDebug, buffer);
}
display_time_increment_ = display_time_increment_ + offs;
}
if (debug_log_display_time_) {
char buffer[256];
snprintf(buffer, sizeof(buffer),
"final %.5f current(%s) %.5f sample %.5f chaos %.5f",
display_time_increment_, use_avg ? "avg" : "sample", used,
this_increment, chaos);
Log(LogLevel::kDebug, buffer);
}
}
// Lastly, apply our updated increment value to our time.
display_time_ += display_time_increment_;
// In this path, our integer values just follow our float ones.
auto prev_microsecs = display_time_microsecs_;
display_time_microsecs_ = static_cast<microsecs_t>(display_time_ * 1000000.0);
display_time_increment_microsecs_ = display_time_microsecs_ - prev_microsecs;
}
// Set up our sleeping based on what we're doing.
void Logic::UpdatePendingWorkTimer_() {
assert(g_base->InLogicThread());
// This might get called before we set up our timer in some cases. (such
// as very early) should be safe to ignore since we update the interval
// explicitly after creating the timers.
if (!process_pending_work_timer_) {
return;
}
// If there's loading to do, keep at it rather vigorously.
if (have_pending_loads_) {
assert(process_pending_work_timer_);
process_pending_work_timer_->SetLength(1 * 1000);
} else {
// Otherwise we've got nothing to do; go to sleep until something
// changes.
assert(process_pending_work_timer_);
process_pending_work_timer_->SetLength(-1);
}
}
void Logic::HandleInterruptSignal() {
assert(g_base->InLogicThread());
// Interrupt signals are 'gentle' requests to shut down.
// Special case; when running under the server-wrapper, we completely
// ignore interrupt signals (the wrapper acts on them).
if (g_base->server_wrapper_managed()) {
return;
}
Shutdown();
}
void Logic::HandleTerminateSignal() {
// Interrupt signals are slightly more stern requests to shut down.
// We always respond to these.
assert(g_base->InLogicThread());
Shutdown();
}
void Logic::Draw() {
assert(g_base->InLogicThread());
assert(!g_core->HeadlessMode());
// Push a snapshot of our current state to be rendered in the graphics
// thread.
g_base->graphics->BuildAndPushFrameDef();
// Now bring logic up to date. By doing this *after* fulfilling the draw
// request, we're minimizing the chance of long logic updates leading to
// delays in frame-def delivery leading to frame drops. The downside is
// that when logic updates are fast then logic is basically sitting around
// twiddling its thumbs and getting a full frame out of date before being
// drawn. But as high frame rates are becoming more normal this becomes
// less and less meaningful and its probably best to prioritize smooth
// visuals.
StepDisplayTime_();
}
void Logic::NotifyOfPendingAssetLoads() {
assert(g_base->InLogicThread());
have_pending_loads_ = true;
UpdatePendingWorkTimer_();
}
auto Logic::NewAppTimer(microsecs_t length, bool repeat, Runnable* runnable)
-> int {
// App-Timers simply get injected into our loop and run alongside our own
// stuff.
assert(g_base->InLogicThread());
auto* timer = event_loop()->NewTimer(length, repeat, runnable);
return timer->id();
}
void Logic::DeleteAppTimer(int timer_id) {
assert(g_base->InLogicThread());
event_loop()->DeleteTimer(timer_id);
}
void Logic::SetAppTimerLength(int timer_id, microsecs_t length) {
assert(g_base->InLogicThread());
Timer* t = event_loop()->GetTimer(timer_id);
if (t) {
t->SetLength(length);
} else {
Log(LogLevel::kError,
"Logic::SetAppTimerLength() called on nonexistent timer.");
}
}
auto Logic::NewDisplayTimer(microsecs_t length, bool repeat, Runnable* runnable)
-> int {
// Display-Timers go into a timer-list that we exec explicitly when we
// step display-time.
assert(g_base->InLogicThread());
int offset = 0;
Timer* t = display_timers_->NewTimer(display_time_microsecs_, length, offset,
repeat ? -1 : 0, runnable);
return t->id();
}
void Logic::DeleteDisplayTimer(int timer_id) {
assert(g_base->InLogicThread());
display_timers_->DeleteTimer(timer_id);
}
void Logic::SetDisplayTimerLength(int timer_id, microsecs_t length) {
assert(g_base->InLogicThread());
Timer* t = display_timers_->GetTimer(timer_id);
if (t) {
t->SetLength(length);
} else {
Log(LogLevel::kError,
"Logic::SetDisplayTimerLength() called on nonexistent timer.");
}
}
void Logic::ProcessPendingWork_() {
have_pending_loads_ = g_base->assets->RunPendingLoadsLogicThread();
UpdatePendingWorkTimer_();
}
void Logic::OnAppActiveChanged() {
assert(g_base->InLogicThread());
// Note: we keep our own active state here in the logic thread and
// simply refresh it from the atomic value from the main thread here.
// There are occasions where the main thread's value flip-flops back
// and forth quickly and we'll generally skip over those this way.
auto app_active = g_base->app_active();
if (app_active != app_active_) {
app_active_ = app_active;
// For now just informing Python (which informs Python level app-mode).
// Can expand this to inform everyone else if needed.
g_base->python->OnAppActiveChanged();
}
}
} // namespace ballistica::base