Greetings.
I made my own black market. It's a website. The gamer will enter their username/password for the game.
Once they authenticate, then they are met with a market to purchase items. You control what is added/removed, etc.
The funds used to purchase, are funds from the Central Market.
In this case, my username is james.
I have 4,519,206,984 in my central market.
I couldn't add Ornette's Spirit Essence to my central market, so I made my own black market. It was easier than expected.
The gamer logs in, clicks buy, and in game, they get a mail with the item they purchased. Their balance in the central market changes immediately. No need to be logged out.
This makes use of:
1. Python 3.9.5 (installed when you install Corsair)
2. Databases (installed when you install Corsair)
3. An open port on your firewall. (in my case, I used 8892)
4. It will lock the database row and change it so misuse is avoided.
5. sendmail which has to be added to your database. I will include that as well.
6. The HTML is built into the python file so no need to build multiple files.
7. You can edit what is on your market, and the prices, around lines 40-50.
'''
BlackMarket
A blackmarket server for black desert.
Author: Njinir
Notes:
Login DB: SA_BETA_WORLDDB_0002 at table PaGamePrivate.TblUserInformation
Target:
Database: [SA_BETA_TRADEDB_0002].[PaGamePrivate].[TblWorldMarketWallet]
Where: _userNo =
and : _mainKey = 1
and : _count = Silver Count for transactions
'''
# blackmarket.py
# Flask + Waitress, Python 3.9.5, ODBC Driver 17, port 8892
import os
from datetime import timedelta
import pyodbc
from flask import (
Flask, request, redirect, url_for, session,
render_template_string, flash
)
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET", "dev-secret") # change in prod
app.permanent_session_lifetime = timedelta(hours=2)
# --- DB config ---
SQL_SERVER = os.environ.get("SQL_SERVER", "localhost")
USE_SQL_AUTH = os.environ.get("USE_SQL_AUTH", "0") == "1" # set to 1 to use SQL auth
SQL_USER = os.environ.get("SQL_USER", "")
SQL_PASS = os.environ.get("SQL_PASS", "")
WORLD_DB = "SA_BETA_WORLDDB_0002"
TRADE_DB = "SA_BETA_TRADEDB_0002"
# --- Demo items (IDs/prices are placeholders) ---
ITEMS = [
{"id": 40770, "name": "Ornette's Spirit Essence", "price": 4_000_000_000, "enchant": 0},
{"id": 40771, "name": "Odore's Spirit Essence", "price": 4_000_000_000, "enchant": 0},
{"id": 16016, "name": "Laffi Bedmountains Compass", "price": 4_000_000_000, "enchant": 0},
{"id": 16019, "name": "Archaeologist's Map", "price": 4_000_000_000, "enchant": 0},
{"id": 50151, "name": "Dream Arduanatt", "price": 5_000_000_000, "enchant": 0},
{"id": 50150, "name": "Dream Dine", "price": 5_000_000_000, "enchant": 0},
{"id": 50149, "name": "Dream Doom", "price": 5_000_000_000, "enchant": 0},
{"id": 47968, "name": "Celestial Horse Horn", "price": 1_000_000_000, "enchant": 0},
{"id": 16080, "name": "Cron Stone x10", "price": 2_000_000, "enchant": 0},
]
def get_conn(database_name: str):
"""Create an ODBC connection to the specified database."""
driver = "{ODBC Driver 17 for SQL Server}"
if USE_SQL_AUTH:
cs = (
f"DRIVER={driver};SERVER={SQL_SERVER};DATABASE={database_name};"
f"UID={SQL_USER};PWD={SQL_PASS};TrustServerCertificate=yes;"
)
else:
cs = (
f"DRIVER={driver};SERVER={SQL_SERVER};DATABASE={database_name};"
f"Trusted_Connection=yes;TrustServerCertificate=yes;"
)
return pyodbc.connect(cs)
def get_wallet_silver(user_no: int) -> int:
"""Read silver from TRADE DB Wallet where _mainKey = 1."""
try:
with get_conn(TRADE_DB) as conn, conn.cursor() as cur:
cur.execute("""
SELECT TOP 1 _count
FROM PaGamePrivate.TblWorldMarketWallet WITH (NOLOCK)
WHERE _userNo = ? AND _mainKey = 1
""", (user_no,))
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else 0
except Exception:
return 0
# ---------- HTML (inline for single-file app) ----------
LOGIN_HTML = """<!doctype html>
<html><head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Black Market – Sign in</title>
<style>
body{margin:0;font-family:system-ui,Segoe UI,Arial;background:#0b0d11;color:#e6e6e6;display:grid;place-items:center;height:100vh}
.card{background:#141922;border:1px solid #232a36;border-radius:16px;box-shadow:0 10px 30px rgba(0,0,0,.35);padding:28px;min-width:300px;max-width:360px}
h1{margin:0 0 6px 0;font-size:22px}
p.sub{margin:0 0 16px 0;color:#aab3c2;font-size:13px}
label{display:block;margin:12px 0 6px 0;font-size:13px;color:#aab3c2}
input{width:100%;padding:10px 12px;border:1px solid #2a3140;background:#0f131a;color:#e6e6e6;border-radius:10px}
button{margin-top:16px;width:100%;padding:10px 12px;border:0;border-radius:10px;background:#3b82f6;color:white;font-weight:600;cursor:pointer}
.err{margin-top:10px;color:#ff7070;font-size:13px}
.msg{margin-top:10px;color:#87d17e;font-size:13px}
</style>
</head>
<body>
<form class="card" method="post" action="/">
<h1>Black Market</h1>
<p class="sub">Sign in</p>
<label>Username</label>
<input name="username" autocomplete="username" required />
<label>Password</label>
<input name="password" type="password" autocomplete="current-password" required />
<button type="submit">Sign in</button>
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% for m in get_flashed_messages() %}<div class="msg">{{ m }}</div>{% endfor %}
</form>
</body></html>"""
HOME_HTML = """<!doctype html>
<html><head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Black Market – Home</title>
<style>
input.qty{width:90px;padding:6px 8px;border-radius:8px;border:1px solid #232a36;background:#0f131a;color:#e6e6e6}
body{background:#0b0d11;color:#e6e6e6;font-family:system-ui,Segoe UI,Arial;margin:0}
header{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;background:#141922;border-bottom:1px solid #232a36}
main{padding:24px}
.meta{color:#aab3c2;font-size:14px;margin:6px 0 18px}
table{width:100%;border-collapse:collapse;margin-top:10px}
th,td{padding:10px;border-bottom:1px solid #232a36;text-align:left}
button{padding:6px 10px;border:0;border-radius:8px;background:#3b82f6;color:#fff;cursor:pointer}
.flash-ok{color:#7be18a;margin:6px 0}
.flash-err{color:#ff7070;margin:6px 0}
</style>
</head>
<body>
<header>
<div><strong>Black Market</strong></div>
<form method="post" action="/logout"><button type="submit">Logout</button></form>
</header>
<main>
<h2>Welcome, {{ username }}</h2>
<div class="meta">Balance: <strong>{{ "{:,}".format(balance) }}</strong> silver</div>
{% for m in get_flashed_messages(category_filter=["ok"]) %}<div class="flash-ok">{{ m }}</div>{% endfor %}
{% for m in get_flashed_messages(category_filter=["err"]) %}<div class="flash-err">{{ m }}</div>{% endfor %}
<table>
<thead><tr><th>Item</th><th>Price (silver)</th><th>Enchant</th><th>Action</th></tr></thead>
<tbody>
{% for it in items %}
<tr>
<td>{{ it.name }}</td>
<td>{{ "{:,}".format(it.price) }}</td>
<td>{{ it.get("enchant", 0) }}</td>
<td>
<form method="post" action="/buy" style="display:flex;gap:8px;align-items:center">
<input type="hidden" name="item_id" value="{{ it.id }}">
<label for="qty-{{ it.id }}" style="font-size:12px;color:#aab3c2">Qty</label>
<input id="qty-{{ it.id }}" class="qty" type="number" name="qty" value="1" min="1" step="1" />
<button type="submit">Buy</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</main>
</body></html>"""
# -------------------- Routes --------------------
@app.route("/", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
error = "Enter both fields."
else:
user_id = f"{username},{password}" # _userId format: "username,password"
try:
with get_conn(WORLD_DB) as conn, conn.cursor() as cur:
cur.execute("""
SELECT TOP 1 _userNo, _userNickname
FROM PaGamePrivate.TblUserInformation WITH (NOLOCK)
WHERE _userId = ?
""", (user_id,))
row = cur.fetchone()
if row:
session.permanent = True
session["username"] = username
session["_userNo"] = int(row[0])
session["_nickname"] = row[1] or username # for @toFamilyName
return redirect(url_for("home"))
else:
error = "Invalid credentials."
except Exception as ex:
error = f"DB error: {ex}"
return render_template_string(LOGIN_HTML, error=error)
@app.route("/home")
def home():
if "username" not in session:
return redirect(url_for("login"))
balance = get_wallet_silver(session["_userNo"])
return render_template_string(
HOME_HTML,
username=session["username"],
balance=balance,
items=ITEMS
)
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
flash("Signed out.", "ok")
return redirect(url_for("login"))
@app.route("/buy", methods=["POST"])
def buy():
if "username" not in session:
return redirect(url_for("login"))
item_id = request.form.get("item_id", "").strip()
qty_raw = request.form.get("qty", "1").strip()
try:
qty = max(1, int(qty_raw))
except ValueError:
qty = 1
# Lookup item from ITEMS (id == itemKey for sendMail)
item = next((i for i in ITEMS if str(i["id"]) == item_id), None)
if not item:
flash("Invalid item.", "err")
return redirect(url_for("home"))
item_key = int(item["id"])
unit_price = int(item["price"]) # e.g., 2_000_000 → 2000000
item_name = item["name"]
enchant = int(item.get("enchant", 0))
total_cost = unit_price * qty
user_no = session["_userNo"]
nickname = session.get("_nickname", session["username"])
# Begin atomic TX: deduct wallet and send mail
try:
conn = get_conn(TRADE_DB)
conn.autocommit = False
cur = conn.cursor()
# Lock wallet row, check balance
cur.execute("""
DECLARE @bal BIGINT;
SELECT @bal = _count
FROM PaGamePrivate.TblWorldMarketWallet WITH (UPDLOCK, ROWLOCK)
WHERE _userNo = ? AND _mainKey = 1;
IF @bal IS NULL
THROW 50001, 'Wallet not found', 1;
IF @bal < ?
THROW 50002, 'Insufficient funds', 1;
""", (user_no, total_cost))
# Deduct
cur.execute("""
UPDATE PaGamePrivate.TblWorldMarketWallet
SET _count = _count - ?
WHERE _userNo = ? AND _mainKey = 1;
""", (total_cost, user_no))
# Send mail (fully-qualified proc name; runs in same TX)
# @toFamilyName NVARCHAR, @itemKey INT, @itemCount INT, @enchant INT,
# @title NVARCHAR, @contents NVARCHAR, @symNo NVARCHAR(OUTPUT)
cur.execute("""
DECLARE @symNo NVARCHAR(50);
EXEC SA_BETA_WORLDDB_0002.dbo.sendMail
@toFamilyName = ?,
@itemKey = ?,
@itemCount = ?,
@enchant = ?,
@title = N'Mail Express',
@contents = N'Mail sent from the administration team.',
@symNo = @symNo OUTPUT;
SELECT @symNo AS symNo;
""", (nickname, item_key, qty, enchant))
sym_row = cur.fetchone()
sym_no = sym_row[0] if (sym_row and sym_row[0]) else None
conn.commit()
cur.close(); conn.close()
msg = f"Purchased {item_name} x{qty} for {total_cost:,} silver."
if sym_no:
msg += f" Mail symNo: {sym_no}"
flash(msg, "ok")
return redirect(url_for("home"))
except Exception as e:
# Roll back any partial changes
try:
conn.rollback()
except Exception:
pass
try:
cur.close(); conn.close()
except Exception:
pass
msg = str(e)
if "50002" in msg or "Insufficient funds" in msg:
flash(f"Insufficient funds. Need {total_cost:,}.", "err")
else:
flash(f"Purchase failed: {msg}", "err")
return redirect(url_for("home"))
# -------------------- Entry point (Waitress) --------------------
if __name__ == "__main__":
from waitress import serve
print("Serving at http://0.0.0.0:8892")
serve(app, host="0.0.0.0", port=8892) # bind only this port/interface
 
Send Mail is easy to work with. Open your SQL server with SQL Server Manager.
Login.
Open the folder that has Databases in it. Find
SA_BETA_WORLDDB_0002 and expand it.
Expand Programability.
Expand Stored Procedures. (This is where the business is).
This sendMail procedure is what I use any time I want to send mail to a user. (pearls/all that).
Ok. here is how.
Right click Stored Procedures and hover over NEW and click Stored Procedure. (We are making a new one).
A template will open. Delete all that stuff. Make it blank. Paste this in and then find the execute button in the menu (up and left from where you are at).
(or press F5)
Refresh. Make sure the new procedure is there called dbo.sendMail
USE [SA_BETA_WORLDDB_0002]
GO
/****** Object: StoredProcedure [dbo].[sendMail] Script Date: 8/28/2025 2:25:51 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
-- This script ALTERS the existing dbo.sendMail procedure in SA_BETA_WORLDDB_0002.
-- The key change is to use three-part naming for the cross-database call to uspSendMailByUserWithEnchant_XXX.
ALTER PROCEDURE [dbo].[sendMail]
@toFamilyName NVARCHAR(50)
,@itemKey BIGINT
,@itemCount BIGINT = 1
,@enchant INT = 0
,@title NVARCHAR(100) = N'Your donation!'
,@contents NVARCHAR(300) = N'Thank you for supporting us!'
,@symNo NVARCHAR(50) OUTPUT -- Meaningful only in case of failure
AS
BEGIN
SET NOCOUNT ON -- Do not generate count-set results.
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
SET LOCK_TIMEOUT 3000 -- Do not change arbitrarily; consult DBA if needed.
SET XACT_ABORT ON
DECLARE @rv INT = 0, -- 0: Success, Negative: Error Code, Positive: Not to be used (handled as ODBC error)
@userNo BIGINT,
@userId NVARCHAR(50)
BEGIN TRANSACTION
-- Using SA_BETA_WORLDDB_0002 explicitly based on your clarification that CORSAIR_WORLDDB is equivalent.
SELECT @userNo=_userNo, @userId=_userId FROM [SA_BETA_WORLDDB_0002].PaGamePrivate.TblUserInformation where _userNickname=@toFamilyName
if @@ROWCOUNT <> 1
begin
set @rv = -1
set @symNo = 'errNoFamilyDoesNotExist'
goto LABEL_END
end
-- *** THE CRITICAL FIX: Explicitly specify the database for the cross-database call ***
EXEC @rv = [SA_BETA_GAMEDB_0002].[PaGamePublic].[uspSendMailByUserWithEnchant_XXX]
@senderName = N'Primal Rage Gaming Team', -- Or N'RogueBD' as seen in your other script
@senderUserNo = 1,
@toUserNo = @userNo,
@title = @title,
@contents = @contents,
@itemKey = @itemKey,
@enchantLevel = @enchant,
@itemCount = @itemCount
if @rv <> 0
begin
set @symNo = 'errNoMailNotSent'
GOTO LABEL_END
end
/*
-- The original script had a commented-out INSERT into EVOBDO_LOGDB_0001.PaGamePrivate.TblItemLog.
-- If you need this logging, it would also require three-part naming if EVOBDO_LOGDB_0001 is a different database.
INSERT INTO [EVOBDO_LOGDB_0001].PaGamePrivate.TblItemLog ([_operationLogType], [_serverNo], [_registerDate], [_userId], [_userNo], [_isUserGm],
[_isPcRoom], [_isPcRoomPremium], [_itemNo], [_itemKey], [_endurance], [_maxEndurance], [_itemWhereType], [_variedCount], [_reason], [_receivingUserId], [_receivingUserNo])
VALUES (
100, -- unsure, could be 101
0, -- who cares
GETDATE(),
'dbo.sendMail',
1, -- userNo
1, -- is GM
0, 0, -- pr room
0, -- itemNo
@itemKey,
0, 0, -- endurance
0, -- where
@itemCount,
50, -- reason: send mail
@userId,
@userNo
)
*/
if @rv <> 0
begin
set @symNo = 'errNoMailNotLogged'
GOTO LABEL_END
end
LABEL_END:
IF(0 = @rv)
BEGIN
COMMIT TRAN
END
ELSE
BEGIN
ROLLBACK TRAN
END
RETURN(@rv)
END