diff --git a/bin/p-score b/bin/p-score
new file mode 100755
index 0000000000000000000000000000000000000000..cd15d5ce30ccbfe08726e41bccd0042252b8a116
--- /dev/null
+++ b/bin/p-score
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+# Generátor výsledkové listiny pro MO-P
+
+import argparse
+from sqlalchemy.orm import joinedload
+
+import mo.db as db
+from mo.score import Score
+from mo.util import die, init_standalone
+
+parser = argparse.ArgumentParser(description='Vygeneruje výsledkovou listinu MO-P')
+parser.add_argument('year', type=int)
+parser.add_argument('seq', type=int)
+
+args = parser.parse_args()
+init_standalone()
+sess = db.get_session()
+
+
+def get_results(round, contests):
+    results = {}
+    for contest in contests:
+        place_code = contest.place.get_code()
+        print(f"Počítám oblast {place_code}")
+
+        score = Score(round, contest)
+        results[place_code] = score.get_sorted_results()
+
+        for msg in score.get_messages():
+            if msg[0] != 'info':
+                print(f'\t{msg[0].upper()}: {msg[1]}')
+
+    return results
+
+
+def write_tex(round, tasks, contests, results):
+    with open('final.tex', 'w') as out:
+        out.write(r'\def\HranicePostupu{%s}' % (round.score_winner_limit,) + "\n")
+        out.write(r'\def\HraniceUspesnychResitelu{%s}' % (round.score_successful_limit,) + "\n")
+        out.write('\n')
+        for c in contests:
+            res = results[c.place.get_code()]
+            if round.seq == 2:
+                out.write(r'\kraj{%s}' % c.place.name + '\n')
+                if not res:
+                    out.write(r'\nobody' + '\n')
+                    out.write(r'\endkraj' + '\n\n')
+                    continue
+
+            out.write(r'\begintable' + '\n')
+            prev_typ = ""
+
+            for r in res:
+                if r.winner:
+                    typ = 'v'
+                elif r.successful:
+                    typ = 'u'
+                else:
+                    typ = 'n'
+                if typ != prev_typ:
+                    if prev_typ:
+                        out.write(r'\sep%s' % typ)
+                    prev_typ = typ
+                out.write(r'\%s' % typ)
+
+                cols = []
+                o = r.order
+                if not r.successful or o.continuation:
+                    cols.append("")
+                elif o.span > 1:
+                    cols.append(f'{o.place}.--{o.place + o.span - 1}.')
+                else:
+                    cols.append(f'{o.place}.')
+
+                cols.append(r.user.full_name())
+                cols.append(r.pant.school_place.name)
+                cols.append(r.pant.grade)
+
+                sol_map = r.get_sols_map()
+                for t in tasks:
+                    s = sol_map.get(t.task_id)
+                    if s is not None:
+                        cols.append(s.points)
+                    else:
+                        cols.append('--')
+
+                cols.append(r.get_total_points())
+
+                out.write("".join(['{' + str(col) + '}' for col in cols]) + '\n')
+
+            out.write(r'\endtable' + '\n')
+            if round.seq == 2:
+                out.write(r'\endkraj' + '\n\n')
+
+
+def write_html(round, tasks, contests, results):
+    num_cols = 4 + len(tasks) + 1
+    with open('final.html', 'w') as out:
+        for c in contests:
+            out.write(f'<tr><th colspan={num_cols}>{c.place.name}\n')
+
+            res = results[c.place.get_code()]
+            if not res:
+                out.write(f'<tr class=nobody><td colspan={num_cols}>Nikdo se nezúčastnil.\n')
+                out.write(f'<tr><td colspan={num_cols}>\n\n')
+                continue
+
+            for r in res:
+                if r.winner:
+                    out.write('<tr class=marked>')
+                elif r.successful:
+                    out.write('<tr class=success>')
+                else:
+                    out.write('<tr>')
+
+                cols = []
+                o = r.order
+                if not r.successful or o.continuation:
+                    cols.append("")
+                elif o.span > 1:
+                    cols.append(f'{o.place}.–{o.place + o.span - 1}.')
+                else:
+                    cols.append(f'{o.place}.')
+
+                cols.append(r.user.full_name())
+                cols.append(r.pant.school_place.name)
+                cols.append(r.pant.grade)
+
+                sol_map = r.get_sols_map()
+                for t in tasks:
+                    s = sol_map.get(t.task_id)
+                    if s is not None:
+                        cols.append(s.points)
+                    else:
+                        cols.append('–')
+
+                cols.append(r.get_total_points())
+
+                out.write("".join(['<td>' + str(col) for col in cols]) + '\n')
+
+            out.write(f'<tr><td colspan={num_cols}>\n\n')
+
+
+round = sess.query(db.Round).filter_by(year=args.year, category='P', seq=args.seq).one()
+print(f"Kolo {round.round_code()}")
+
+tasks = sess.query(db.Task).filter_by(round=round).order_by(db.Task.code).all()
+
+contests = (sess.query(db.Contest)
+            .filter_by(round=round)
+            .options(joinedload(db.Contest.place))
+            .all())
+assert contests
+contests.sort(key=lambda c: c.place.get_code())
+
+results = get_results(round, contests)
+write_tex(round, tasks, contests, results)
+write_html(round, tasks, contests, results)