diff --git a/lua/prescient-trains/.gitignore b/lua/prescient-trains/.gitignore
new file mode 100644
index 0000000..35048ea
--- /dev/null
+++ b/lua/prescient-trains/.gitignore
@@ -0,0 +1,2 @@
+stdlib
+prototypes/debug.txt
diff --git a/lua/prescient-trains/.neoconf.json b/lua/prescient-trains/.neoconf.json
new file mode 100644
index 0000000..99e3e78
--- /dev/null
+++ b/lua/prescient-trains/.neoconf.json
@@ -0,0 +1,33 @@
+{
+  "lspconfig": {
+    "sumneko_lua": {
+      "Lua.workspace.library": [
+        "/home/adrielus/Projects/Foreign/vscode-factoriomod-debug/sumneko-3rd/factorio/library",
+        "/home/adrielus/.steam/steam/steamapps/common/Factorio/data"
+      ],
+      "Lua.runtime.plugin": "/home/adrielus/Projects/Foreign/vscode-factoriomod-debug/sumneko-3rd/factorio/plugin.lua",
+      "Lua.workspace.checkThirdParty": false,
+      "Lua.runtime.version": "Lua 5.2",
+      "Lua.runtime.special": {
+        "__object_name": "type"
+      },
+      "Lua.runtime.builtin": {
+        "io": "disable",
+        "os": "disable",
+        "coroutine": "disable",
+        "package": "disable",
+        "math": "disable",
+        "debug": "disable"
+      },
+      "Lua.diagnostics.globals": [
+        "__DebugAdapter",
+        "__Profiler"
+      ]
+    }
+  },
+  "neodev": {
+    "library": {
+      "enabled": false
+    }
+  }
+}
diff --git a/lua/prescient-trains/common/constants.lua b/lua/prescient-trains/common/constants.lua
new file mode 100644
index 0000000..e39cf4d
--- /dev/null
+++ b/lua/prescient-trains/common/constants.lua
@@ -0,0 +1,11 @@
+local M = {}
+
+M.item_name = "dispatcher-train-stop"
+M.input_item_name = M.item_name .. "-input"
+
+-- Distance from rails for each stop type.
+-- Useful if, in the future, I decide to
+-- add support for cargo ships
+M.stop_offsets = { [M.item_name] = 0 }
+
+return M
diff --git a/lua/prescient-trains/control.lua b/lua/prescient-trains/control.lua
new file mode 100644
index 0000000..3bd3b8e
--- /dev/null
+++ b/lua/prescient-trains/control.lua
@@ -0,0 +1 @@
+require("scripts.train_stop_events").setup()
diff --git a/lua/prescient-trains/data.lua b/lua/prescient-trains/data.lua
new file mode 100644
index 0000000..3837f71
--- /dev/null
+++ b/lua/prescient-trains/data.lua
@@ -0,0 +1,4 @@
+require("prototypes.technologies")
+require("prototypes.recipes")
+require("prototypes.items")
+require("prototypes.entities")
diff --git a/lua/prescient-trains/info.json b/lua/prescient-trains/info.json
new file mode 100644
index 0000000..d56a08c
--- /dev/null
+++ b/lua/prescient-trains/info.json
@@ -0,0 +1,9 @@
+{
+  "name": "tinker-with-schedules",
+  "version": "0.1.0",
+  "title": "Tinker with schedules",
+  "author": "Adriel",
+  "factorio_version": "1.1",
+  "dependencies": ["base >= 1.1", "stdlib >= 1.4"],
+  "description": "Let's you use combinators to add temporary stops to train schedules"
+}
diff --git a/lua/prescient-trains/locale/en/en.cfg b/lua/prescient-trains/locale/en/en.cfg
new file mode 100644
index 0000000..76c2708
--- /dev/null
+++ b/lua/prescient-trains/locale/en/en.cfg
@@ -0,0 +1,25 @@
+[mod-setting-name]
+
+[mod-setting-description]
+
+[entity-name]
+dispatcher-train-stop=Dispatcher
+dispatcher-train-stop-input=Dispatcher input
+
+[entity-description]
+dispatcher-train-stop=Allows you to add temporary stops to train schedules using signals
+dispatcher-train-stop-input=Listens for signals coming from the exterior
+
+[item-name]
+dispatcher-train-stop=Dispatcher
+dispatcher-train-stop-input=Dispatcher input
+
+[item-description]
+dispatcher-train-stop=Allows you to add temporary stops to train schedules using signals
+dispatcher-train-stop-input=Uh, why is this here
+
+[technology-name]
+tinker-with-schedules=Tinker with schedules
+
+[technology-description]
+tinker-with-schedules=Allows you to add temporary stops to train schedules using signals
diff --git a/lua/prescient-trains/prototypes/entities.lua b/lua/prescient-trains/prototypes/entities.lua
new file mode 100644
index 0000000..ebacf1b
--- /dev/null
+++ b/lua/prescient-trains/prototypes/entities.lua
@@ -0,0 +1,44 @@
+local C = require("common.constants")
+
+local function copy(from, name)
+    local copied = table.deepcopy(from)
+    copied.name = name
+    copied.minable.result = name
+
+    return copied
+end
+
+-- Train stop
+-- {{{
+local dispatcher_train_stop = copy(data.raw["train-stop"]["train-stop"],
+                                   C.item_name)
+
+dispatcher_train_stop.color = {r = 0.46, g = 0.01, b = 0.98, a = 1}
+dispatcher_train_stop.next_upgrade = nil
+dispatcher_train_stop.selection_box = {{-0.9, -0.6}, {0.9, 0.9}}
+-- }}}
+
+-- Input
+-- {{{
+local dispatcher_train_stop_in = copy(data.raw["lamp"]["small-lamp"],
+                                      C.input_item_name)
+
+dispatcher_train_stop_in.minable = nil
+dispatcher_train_stop_in.next_upgrade = nil
+
+dispatcher_train_stop_in.selection_box = {{-0.5, -0.5}, {0.5, 0.5}}
+dispatcher_train_stop_in.selection_priority =
+    (dispatcher_train_stop_in.selection_priority or 50) + 10
+
+dispatcher_train_stop_in.collision_box = {{-0.15, -0.15}, {0.15, 0.15}}
+dispatcher_train_stop_in.collision_mask = {"rail-layer"} -- collide only with rail entities
+
+dispatcher_train_stop_in.light = {intensity = 1, size = 6}
+dispatcher_train_stop_in.energy_source = {type = "void"}
+dispatcher_train_stop_in.energy_usage_per_tick = "10W"
+dispatcher_train_stop_in.selectable_in_game = true
+
+dispatcher_train_stop_in.flags = {"placeable-off-grid", "player-creation"}
+-- }}}
+
+data:extend({dispatcher_train_stop, dispatcher_train_stop_in})
diff --git a/lua/prescient-trains/prototypes/items.lua b/lua/prescient-trains/prototypes/items.lua
new file mode 100644
index 0000000..d79fd31
--- /dev/null
+++ b/lua/prescient-trains/prototypes/items.lua
@@ -0,0 +1,24 @@
+local C = require("common.constants")
+
+local item_dispatcher_train_stop =
+    table.deepcopy(data.raw["item"]["train-stop"])
+
+item_dispatcher_train_stop.name = C.item_name
+item_dispatcher_train_stop.order = item_dispatcher_train_stop.order .. "-c"
+item_dispatcher_train_stop.icon_size = 64
+item_dispatcher_train_stop.place_result = C.item_name
+item_dispatcher_train_stop.icons = {
+    {
+        icon = "__base__/graphics/icons/train-stop.png",
+        tint = {r = 0.46, g = 0.01, b = 0.98, a = 0.1}
+    }
+}
+
+local item_dispatcher_train_stop_in = table.deepcopy(
+                                          data.raw["item"]["small-lamp"])
+
+item_dispatcher_train_stop_in.name = C.input_item_name
+item_dispatcher_train_stop_in.place_result = C.input_item_name
+item_dispatcher_train_stop_in.flags = {"hidden"}
+
+data:extend({item_dispatcher_train_stop, item_dispatcher_train_stop_in})
diff --git a/lua/prescient-trains/prototypes/recipes.lua b/lua/prescient-trains/prototypes/recipes.lua
new file mode 100644
index 0000000..9ae748d
--- /dev/null
+++ b/lua/prescient-trains/prototypes/recipes.lua
@@ -0,0 +1,14 @@
+local C = require("common.constants")
+
+data:extend({
+    {
+        type = "recipe",
+        name = C.item_name,
+        energy_required = 5,
+        enabled = false,
+        ingredients = {
+            {"train-stop", 1}, {"electronic-circuit", 20}, {"copper-cable", 20}
+        },
+        result = C.item_name
+    }
+})
diff --git a/lua/prescient-trains/prototypes/technologies.lua b/lua/prescient-trains/prototypes/technologies.lua
new file mode 100644
index 0000000..00da358
--- /dev/null
+++ b/lua/prescient-trains/prototypes/technologies.lua
@@ -0,0 +1,20 @@
+local C = require("common.constants")
+
+data:extend({
+  {
+    type = "technology",
+    name = "tinker-with-schedules",
+    icon_size = 256,
+    icon = "__base__/graphics/technology/automated-rail-transportation.png",
+    effects = { { type = "unlock-recipe", recipe = C.item_name } },
+    prerequisites = { "automated-rail-transportation", "circuit-network" },
+    unit = {
+      count = 100,
+      ingredients = {
+        { "automation-science-pack", 1 }, { "logistic-science-pack", 1 }
+      },
+      time = 15
+    },
+    order = "c-g-aa"
+  }
+})
diff --git a/lua/prescient-trains/scripts/helpers.lua b/lua/prescient-trains/scripts/helpers.lua
new file mode 100644
index 0000000..57ed351
--- /dev/null
+++ b/lua/prescient-trains/scripts/helpers.lua
@@ -0,0 +1,73 @@
+local M = {}
+
+-- See [this original implementation](https://github.com/coltonj96/UsefulCombinators/blob/master/UsefulCombinators_0.4.4/control.lua#L2062)
+---Get the total count of some signal in some control behavior.
+---@param control LuaControlBehavior
+---@param signal SignalID
+function M.get_signal_count(control, signal)
+  if not signal then
+    return 0
+  end
+
+  local red = control.get_circuit_network(defines.wire_type.red)
+  local green = control.get_circuit_network(defines.wire_type.green)
+
+  local total = 0
+
+  if red then
+    total = total + (red.get_signal(signal) or 0)
+  end
+
+  if green then
+    total = total + (green.get_signal(signal) or 0)
+  end
+
+  return total
+end
+
+---Returns the index if going past #array or under 1 would loop back the other side.
+---Assumes the array is nonempty
+---@generic T
+---@param array T[]
+---@param index integer
+---@returns integer
+---@nodiscard
+function M.mod_index(array, index)
+  return (index - 1) % #array + 1
+end
+
+---Uses the above function to normalize the index between the start and end of the array.
+---Assumes the array is nonempty
+---@generic T
+---@param array T[]
+---@param index integer
+---@return T
+---@nodiscard
+function M.get_cycled(array, index)
+  return array[M.mod_index(array, index)]
+end
+
+---Annotated version of table.inset
+---@generic T
+---@param list T[]
+---@param at integer
+---@param value T
+function M.list_insert(list, at, value)
+  table.insert(list, at, value)
+end
+
+---Shallow copies some table
+---@generic T :table
+---@param object T
+---@return T
+function M.copy(object)
+  local result = {}
+
+  for k, v in pairs(object) do
+    result[k] = v
+  end
+
+  return result
+end
+
+return M
diff --git a/lua/prescient-trains/scripts/settings.lua b/lua/prescient-trains/scripts/settings.lua
new file mode 100644
index 0000000..18c7a79
--- /dev/null
+++ b/lua/prescient-trains/scripts/settings.lua
@@ -0,0 +1,6 @@
+local M = {}
+
+M.message_level = tonumber(settings.global["tws-interface-console-level"].value)
+M.debug_log = settings.global["tws-interface-debug-logfile"].value
+
+return M
diff --git a/lua/prescient-trains/scripts/train_stop_events.lua b/lua/prescient-trains/scripts/train_stop_events.lua
new file mode 100644
index 0000000..ca90ac8
--- /dev/null
+++ b/lua/prescient-trains/scripts/train_stop_events.lua
@@ -0,0 +1,313 @@
+local Entity = require("__stdlib__/stdlib/entity/entity")
+local Direction = require("__stdlib__/stdlib/area/direction")
+local Position = require("__stdlib__/stdlib/area/position")
+local Area = require("__stdlib__/stdlib/area/area")
+
+local H = require("scripts.helpers")
+local C = require("common.constants")
+
+-- local settings = require("scripts.settings")
+
+local M = {}
+
+---@class DispatcherTrainStopData
+---@field entity LuaEntity
+---@field input LuaEntity
+
+---Gets the data attached to a dispatcher
+---@param entity LuaEntity
+---@return DispatcherTrainStopData|nil
+---@nodiscard
+local function get_dispatcher_data(entity)
+  if not global.tws_dispatchers then
+    return nil
+  end
+
+  return global.tws_dispatchers[entity.unit_number]
+end
+
+---Attaches some data to a dispatcher
+---@param entity LuaEntity
+---@param value DispatcherTrainStopData|nil
+local function set_dispatcher_data(entity, value)
+  if not global.tws_dispatchers then
+    global.tws_dispatchers = {}
+  end
+
+  global.tws_dispatchers[entity.unit_number] = value
+end
+
+-- {{{ CreateStop
+---Runs when a stop gets created
+---@param entity LuaEntity
+local function CreateStop(entity)
+  if get_dispatcher_data(entity) then
+    -- TODO: better logs
+    game.print("Duplicate unit number, wtf")
+    return
+  end
+
+  local stop_offset = C.stop_offsets[entity.name]
+  local offset_adjustment = (not stop_offset) and stop_offset
+    or Direction.to_vector(Direction.next(entity.direction), stop_offset)
+  -- local offset_adjustment = 0
+  ---@type MapPosition
+  local input_position = Position(entity.position)
+    + offset_adjustment
+    -- Moves the center we rotate around to the
+    -- center of the top-left block,
+    -- Position(0.5, 0.5) +
+    -- then rotates counterclockwise once,
+    -- and go 0.5 more in that direction
+    -- Direction.to_vector(
+    --     Direction.next(entity.direction, true), 0.5)
+    + Direction.to_vector(entity.direction, 0.5)
+
+  local search_area = Area.shrink(Area.new({ input_position }), 0.001)
+
+  local input
+
+  -- {{{ Handle blueprint ghosts and existing IO entities preserving circuit connections
+  local ghosts = entity.surface.find_entities(search_area)
+  for _, ghost in pairs(ghosts) do
+    if ghost.valid then
+      if ghost.name == "entity-ghost" then
+        if ghost.ghost_name == C.input_item_name then
+          _, input = ghost.revive()
+        end
+
+        -- something has built I/O already (e.g.) Creative Mode Instant Blueprint
+      elseif ghost.name == C.input_item_name then
+        input = ghost
+      end
+    end
+  end
+  -- }}}
+
+  if input == nil then -- create new
+    input = entity.surface.create_entity({
+      name = C.input_item_name,
+      position = input_position,
+      force = entity.force,
+    })
+  end
+
+  if input == nil then
+    -- TODO: logging
+    game.print("Something went wrong")
+    return
+  end
+
+  input.operable = false -- disable gui
+  input.minable = false
+  input.destructible = false -- don't bother checking if alive
+
+  ---@type DispatcherTrainStopData
+  local stop_data = { entity = entity, input = input }
+
+  set_dispatcher_data(entity, stop_data)
+end
+
+-- }}}
+-- {{{ OnEntityCreated
+local function OnEntityCreated(event)
+  local entity = event.created_entity or event.entity or event.destination
+
+  if not entity or not entity.valid then
+    return
+  end
+
+  if entity.name == C.item_name then
+    CreateStop(entity)
+  end
+end
+
+-- }}}
+-- {{{ RemoveStop
+---Runs once a train stop has been removed.
+---@param entity LuaEntity
+---@param create_ghosts boolean
+function RemoveStop(entity, create_ghosts)
+  local stop = get_dispatcher_data(entity)
+
+  -- {{{ Destroy io entities
+  if stop then
+    ---@type LuaEntity
+    local input = stop.input
+
+    if input and input.valid then
+      if create_ghosts then
+        input.destructable = true
+        input.die()
+      else
+        input.destroy()
+      end
+    end
+  end
+  -- }}}
+
+  -- Delete entity data
+  set_dispatcher_data(entity, nil)
+end
+
+-- }}}
+-- {{{ OnEntityRemoved
+---Runs when any kind of entity has been removed.
+---@param event EventData.on_entity_died|EventData.script_raised_destroy|EventData.on_robot_pre_mined|EventData.on_pre_player_mined_item
+---@param create_ghosts any
+function OnEntityRemoved(event, create_ghosts)
+  local entity = event.entity
+
+  if not entity or not entity.valid then
+    return
+  end
+
+  if entity.name == C.item_name then
+    RemoveStop(entity, create_ghosts)
+  end
+end
+
+-- }}}
+-- {{{ Remove entity data when surfaces get removed.
+---@param event EventData.on_pre_surface_deleted|EventData.on_pre_surface_cleared
+function OnSurfaceRemoved(event)
+  -- stop references
+  local surfaceID = event.surface_index
+  local surface = game.surfaces[surfaceID]
+
+  if surface then
+    local train_stops = surface.find_entities_filtered({ type = "train-stop" })
+
+    for _, entity in pairs(train_stops) do
+      if entity.name == C.item_name then
+        RemoveStop(entity, false)
+      end
+    end
+  end
+end
+
+-- }}}
+-- {{{ UpdateSchedule
+---Runs every tick to update train schedules
+---@param stop DispatcherTrainStopData
+function UpdateSchedule(stop)
+  local control = stop.input.get_or_create_control_behavior()
+
+  if not control or not control.valid then
+    return
+  end
+
+  local signals = {
+    action_create = { type = "virtual", name = "signal-C" },
+    action_jump = { type = "virtual", name = "signal-J" },
+    target_after = { type = "virtual", name = "signal-A" },
+    target_before = { type = "virtual", name = "signal-B" },
+    template_past = { type = "virtual", name = "signal-P" },
+    template_future = { type = "virtual", name = "signal-F" },
+    option_infinite = { type = "virtual", name = "signal-O" },
+    option_extend = { type = "virtual", name = "signal-E" },
+  }
+
+  local create = H.get_signal_count(control, signals.action_create)
+  local jump = H.get_signal_count(control, signals.action_jump)
+
+  local train = stop.entity.get_stopped_train()
+
+  if not train or not train.valid then
+    return
+  end
+
+  local schedule = train.schedule
+
+  if not schedule then
+    return
+  end
+
+  schedule = H.copy(schedule)
+
+  if create > 0 then
+    local target_after = H.get_signal_count(control, signals.target_after)
+    local target_before = H.get_signal_count(control, signals.target_before)
+    local target_index = schedule.current + target_after - target_before
+
+    local template_future = H.get_signal_count(control, signals.template_future)
+    local template_past = H.get_signal_count(control, signals.template_past)
+    local template_index = schedule.current + template_future - template_past
+
+    local template = H.get_cycled(schedule.records, template_index)
+    local copy = H.copy(template)
+
+    local is_temporary = 0 == H.get_signal_count(control, signals.option_infinite)
+    copy.temporary = is_temporary
+
+    local extend = H.get_signal_count(control, signals.option_extend)
+
+    if extend > 0 and copy.station then
+      copy.station = copy.station .. " " .. tostring(extend)
+    end
+
+    H.list_insert(schedule.records, target_index, copy)
+
+    train.schedule = schedule
+  end
+
+  if jump > 0 and #schedule.records > 0 then
+    schedule.current = (schedule.current + jump - 1) % #schedule.records + 1
+
+    train.schedule = schedule
+  end
+end
+
+-- }}}
+-- {{{ OnTick
+function OnTick()
+  for _, v in pairs(global.tws_dispatchers or {}) do
+    UpdateSchedule(v)
+  end
+end
+
+-- }}}
+
+function M.setup()
+  local filters_on_built = { { filter = "type", type = "train-stop" } }
+  local filters_on_mined = { { filter = "type", type = "train-stop" } }
+
+  -- {{{ On create events
+  local on_create_events = {
+    defines.events.on_built_entity,
+    defines.events.on_robot_built_entity,
+    defines.events.script_raised_built,
+    defines.events.script_raised_revive,
+    defines.events.on_entity_cloned,
+  }
+
+  for _, event in pairs(on_create_events) do
+    script.on_event(event, OnEntityCreated, filters_on_built)
+  end
+  -- }}}
+  -- {{{ On remove events
+  local on_remove_events = {
+    defines.events.on_pre_player_mined_item,
+    defines.events.on_robot_pre_mined,
+    defines.events.script_raised_destroy,
+  }
+
+  for _, event in pairs(on_remove_events) do
+    script.on_event(event, OnEntityRemoved, filters_on_mined)
+  end
+
+  script.on_event(defines.events.on_entity_died, function(event)
+    OnEntityRemoved(event, true)
+  end, filters_on_mined)
+  -- }}}
+  -- {{{ On surface removed
+  script.on_event({
+    defines.events.on_pre_surface_deleted,
+    defines.events.on_pre_surface_cleared,
+  }, OnSurfaceRemoved)
+  -- }}}
+
+  script.on_event(defines.events.on_tick, OnTick)
+end
+
+return M
diff --git a/lua/prescient-trains/settings_.lua b/lua/prescient-trains/settings_.lua
new file mode 100644
index 0000000..6675811
--- /dev/null
+++ b/lua/prescient-trains/settings_.lua
@@ -0,0 +1,16 @@
+data:extend({
+    {
+        type = "bool-setting",
+        name = "tws-interface-debug-logfile",
+        order = "ah",
+        setting_type = "runtime-global",
+        default_value = false
+    }, {
+        type = "string-setting",
+        name = "tws-interface-console-level",
+        order = "ad",
+        setting_type = "runtime-global",
+        default_value = "2",
+        allowed_values = {"0", "1", "2", "3"}
+    }
+})
diff --git a/lua/prescient-trains/stylua.toml b/lua/prescient-trains/stylua.toml
new file mode 100644
index 0000000..03d2f90
--- /dev/null
+++ b/lua/prescient-trains/stylua.toml
@@ -0,0 +1,3 @@
+column_width = 80
+indent_width = 2
+indent_type = "Spaces"